From 42f1d10f4549bfe31e55056c335aa98f5b8a1ae1 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 24 Jun 2026 09:25:02 +0100 Subject: [PATCH 01/58] fixed creation --- .../views/components/html-editor.blade.php | 39 ++++++++++++++----- .../views/livewire/ticket-create.blade.php | 2 +- .../TicketCreateEditorSyncFilesTest.php | 34 ++++++++++------ Modules/Helpdesk/changelog.yaml | 8 ++++ Modules/Helpdesk/module.json | 2 +- 5 files changed, 62 insertions(+), 23 deletions(-) diff --git a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php index 1491907c..6e8a4218 100644 --- a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php +++ b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php @@ -14,29 +14,49 @@
whereDoesntStartWith('wire:model')->merge(['class' => 'space-y-2']) }} @if ($model) data-helpdesk-editor-model="{{ $model }}" @endif x-on:helpdesk-editor-reset.window="if ($event.detail.model === model) clear()" - x-on:helpdesk-editor-sync.window="if ($event.detail.model === model) sync()" x-data="{ + x-on:helpdesk-editor-sync.window="if ($event.detail.model === model) syncToLivewire()" + x-data="{ model: @js($model), value: @js($initialValue), - sync() { + submitHandler: null, + readEditor() { this.value = this.$refs.editor.innerHTML; + + return this.value; + }, + syncToHiddenInput() { + this.readEditor(); + }, + syncToLivewire(live = false) { + this.readEditor(); + if (this.model) { - this.$wire.set(this.model, this.value); + this.$wire.set(this.model, this.value, live); } }, command(action, value = null) { this.$refs.editor.focus(); document.execCommand(action, false, value); - this.sync(); + this.syncToHiddenInput(); }, clear() { this.value = ''; this.$refs.editor.innerHTML = ''; + if (this.model) { - this.$wire.set(this.model, ''); + this.$wire.set(this.model, '', false); } }, init() { this.$refs.editor.innerHTML = this.value || ''; + + this.submitHandler = () => this.syncToLivewire(false); + this.$root.closest('form')?.addEventListener('submit', this.submitHandler, { capture: true }); + }, + destroy() { + if (this.submitHandler) { + this.$root.closest('form')?.removeEventListener('submit', this.submitHandler, { capture: true }); + } } }"> @@ -66,16 +86,15 @@ class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Clear
-
-

- Browser spellcheck is enabled for this rich editor. Use your browser or operating-system spelling menu for suggestions. + Browser spellcheck is enabled for this rich editor. Misspelled words are checked by the browser/operating system while typing.

@if ($model) diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index f3ffdcf5..32b2db16 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -7,7 +7,7 @@ 'Raise a new support request with priority, product/service, project, category, team and full details.', ]) -
+

Ticket details

diff --git a/Modules/Helpdesk/Tests/Feature/TicketCreateEditorSyncFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketCreateEditorSyncFilesTest.php index 6dd16e76..d7a04b45 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketCreateEditorSyncFilesTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketCreateEditorSyncFilesTest.php @@ -9,23 +9,35 @@ use Tests\TestCase; final class TicketCreateEditorSyncFilesTest extends TestCase { /** - * The ticket create editor must bind the details body through Livewire - * without eager live validation while the user is typing. + * The ticket create editor must not send Livewire requests while typing. + * Eager key/input sync causes disabled submit states, random content + * replacement, and prevents native spellcheck from settling. */ - public function test_ticket_create_editor_pushes_details_body_to_livewire_before_validation(): void + public function test_ticket_create_editor_does_not_sync_livewire_on_keypress_or_input(): void + { + $editor = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php')); + + $this->assertIsString($editor); + $this->assertStringNotContainsString('x-on:input', $editor); + $this->assertStringNotContainsString('x-on:keyup', $editor); + $this->assertStringContainsString('contenteditable="true"', $editor); + $this->assertStringContainsString('spellcheck="true"', $editor); + $this->assertStringContainsString('this.$wire.set(this.model, this.value, live)', $editor); + $this->assertStringContainsString('syncToLivewire(false)', $editor); + } + + /** + * The create form must push the rich editor body to the Livewire property + * during submit capture, before the save action validates the body field. + */ + public function test_ticket_create_form_syncs_body_before_submit_validation(): void { $view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php')); $this->assertIsString($view); $this->assertStringContainsString('wire:model="body"', $view); + $this->assertStringContainsString('x-on:submit.capture', $view); + $this->assertStringContainsString('helpdesk-editor-sync', $view); $this->assertStringNotContainsString('wire:model.live="body"', $view); - $this->assertStringContainsString('submit', $view); - $this->assertTrue( - str_contains($view, 'Livewire') - || str_contains($view, '$wire') - || str_contains($view, '@this') - || str_contains($view, 'wire:model="body"'), - 'Ticket create editor must keep the body field synchronised with Livewire before validation.' - ); } } diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 07d4484e..5cdd517c 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -228,3 +228,11 @@ - Mirrored manual time-log entries into helpdesk_ticket_time_logs when the timer history table is present so the ticket detail modal stays current. - Updated TicketTimeLogTest to use authorised Helpdesk fixture users and lookup data instead of ad-hoc users that fail the ticket update policy. - Kept the pricing-free dashboard test baseline and preserved append-only changelog history. + +- version: 1.9.52 + date: 2026-06-24 + changes: + - Fixed ticket create rich editor Livewire synchronisation so typing no longer fires keypress/input requests. + - Restored native browser spellcheck on the contenteditable editor by allowing typed text to remain stable in the DOM. + - Added submit-capture body synchronisation before validation so tickets can be created reliably. + - Added regression assertions for editor spellcheck, keypress sync prevention, and submit-time body sync. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 2eb95df9..5d826b68 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.50" + "version": "1.9.52" } From c5335dc6354b3a1f4c4d961bb2d8a2eebc501729 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 24 Jun 2026 09:27:27 +0100 Subject: [PATCH 02/58] new chatgpt module for api intergration --- Modules/ChatGPT/Config/.gitkeep | 0 Modules/ChatGPT/Config/config.php | 8 + Modules/ChatGPT/Console/.gitkeep | 0 Modules/ChatGPT/Database/Migrations/.gitkeep | 0 Modules/ChatGPT/Database/Seeders/.gitkeep | 0 .../Seeders/ChatGPTDatabaseSeeder.php | 21 +++ Modules/ChatGPT/Database/factories/.gitkeep | 0 Modules/ChatGPT/Helpers/.gitkeep | 0 Modules/ChatGPT/Helpers/Helper.php | 12 ++ Modules/ChatGPT/Http/Controllers/.gitkeep | 0 .../Http/Controllers/ChatGPTController.php | 79 +++++++++ Modules/ChatGPT/Http/Middleware/.gitkeep | 0 Modules/ChatGPT/Http/Requests/.gitkeep | 0 Modules/ChatGPT/Models/.gitkeep | 0 Modules/ChatGPT/Providers/.gitkeep | 0 .../Providers/ChatGPTServiceProvider.php | 155 ++++++++++++++++++ .../Providers/RouteServiceProvider.php | 69 ++++++++ Modules/ChatGPT/Resources/assets/.gitkeep | 0 Modules/ChatGPT/Resources/assets/js/app.js | 0 .../ChatGPT/Resources/assets/sass/app.scss | 0 Modules/ChatGPT/Resources/lang/.gitkeep | 0 Modules/ChatGPT/Resources/views/.gitkeep | 0 .../ChatGPT/Resources/views/index.blade.php | 9 + .../Resources/views/layouts/master.blade.php | 19 +++ Modules/ChatGPT/Routes/.gitkeep | 0 Modules/ChatGPT/Routes/api.php | 18 ++ Modules/ChatGPT/Routes/web.php | 16 ++ Modules/ChatGPT/Support/.gitkeep | 0 Modules/ChatGPT/Support/ModuleInfo.php | 26 +++ Modules/ChatGPT/Tests/Browser/.gitkeep | 0 Modules/ChatGPT/Tests/Feature/.gitkeep | 0 Modules/ChatGPT/Tests/Unit/.gitkeep | 0 Modules/ChatGPT/Traits/.gitkeep | 0 Modules/ChatGPT/View/.gitkeep | 0 Modules/ChatGPT/composer.json | 29 ++++ Modules/ChatGPT/module.json | 14 ++ Modules/ChatGPT/package.json | 17 ++ Modules/ChatGPT/readme.md | 0 Modules/ChatGPT/webpack.mix.js | 11 ++ modules_statuses.json | 3 +- 40 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 Modules/ChatGPT/Config/.gitkeep create mode 100644 Modules/ChatGPT/Config/config.php create mode 100644 Modules/ChatGPT/Console/.gitkeep create mode 100644 Modules/ChatGPT/Database/Migrations/.gitkeep create mode 100644 Modules/ChatGPT/Database/Seeders/.gitkeep create mode 100644 Modules/ChatGPT/Database/Seeders/ChatGPTDatabaseSeeder.php create mode 100644 Modules/ChatGPT/Database/factories/.gitkeep create mode 100644 Modules/ChatGPT/Helpers/.gitkeep create mode 100644 Modules/ChatGPT/Helpers/Helper.php create mode 100644 Modules/ChatGPT/Http/Controllers/.gitkeep create mode 100644 Modules/ChatGPT/Http/Controllers/ChatGPTController.php create mode 100644 Modules/ChatGPT/Http/Middleware/.gitkeep create mode 100644 Modules/ChatGPT/Http/Requests/.gitkeep create mode 100644 Modules/ChatGPT/Models/.gitkeep create mode 100644 Modules/ChatGPT/Providers/.gitkeep create mode 100644 Modules/ChatGPT/Providers/ChatGPTServiceProvider.php create mode 100644 Modules/ChatGPT/Providers/RouteServiceProvider.php create mode 100644 Modules/ChatGPT/Resources/assets/.gitkeep create mode 100644 Modules/ChatGPT/Resources/assets/js/app.js create mode 100644 Modules/ChatGPT/Resources/assets/sass/app.scss create mode 100644 Modules/ChatGPT/Resources/lang/.gitkeep create mode 100644 Modules/ChatGPT/Resources/views/.gitkeep create mode 100644 Modules/ChatGPT/Resources/views/index.blade.php create mode 100644 Modules/ChatGPT/Resources/views/layouts/master.blade.php create mode 100644 Modules/ChatGPT/Routes/.gitkeep create mode 100644 Modules/ChatGPT/Routes/api.php create mode 100644 Modules/ChatGPT/Routes/web.php create mode 100644 Modules/ChatGPT/Support/.gitkeep create mode 100644 Modules/ChatGPT/Support/ModuleInfo.php create mode 100644 Modules/ChatGPT/Tests/Browser/.gitkeep create mode 100644 Modules/ChatGPT/Tests/Feature/.gitkeep create mode 100644 Modules/ChatGPT/Tests/Unit/.gitkeep create mode 100644 Modules/ChatGPT/Traits/.gitkeep create mode 100644 Modules/ChatGPT/View/.gitkeep create mode 100644 Modules/ChatGPT/composer.json create mode 100644 Modules/ChatGPT/module.json create mode 100644 Modules/ChatGPT/package.json create mode 100644 Modules/ChatGPT/readme.md create mode 100644 Modules/ChatGPT/webpack.mix.js diff --git a/Modules/ChatGPT/Config/.gitkeep b/Modules/ChatGPT/Config/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Config/config.php b/Modules/ChatGPT/Config/config.php new file mode 100644 index 00000000..559be757 --- /dev/null +++ b/Modules/ChatGPT/Config/config.php @@ -0,0 +1,8 @@ + 'ChatGPT', + 'features' => + [ + ], +]; diff --git a/Modules/ChatGPT/Console/.gitkeep b/Modules/ChatGPT/Console/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Database/Migrations/.gitkeep b/Modules/ChatGPT/Database/Migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Database/Seeders/.gitkeep b/Modules/ChatGPT/Database/Seeders/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Database/Seeders/ChatGPTDatabaseSeeder.php b/Modules/ChatGPT/Database/Seeders/ChatGPTDatabaseSeeder.php new file mode 100644 index 00000000..11c67338 --- /dev/null +++ b/Modules/ChatGPT/Database/Seeders/ChatGPTDatabaseSeeder.php @@ -0,0 +1,21 @@ +call("OthersTableSeeder"); + } +} diff --git a/Modules/ChatGPT/Database/factories/.gitkeep b/Modules/ChatGPT/Database/factories/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Helpers/.gitkeep b/Modules/ChatGPT/Helpers/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Helpers/Helper.php b/Modules/ChatGPT/Helpers/Helper.php new file mode 100644 index 00000000..71c25d44 --- /dev/null +++ b/Modules/ChatGPT/Helpers/Helper.php @@ -0,0 +1,12 @@ +registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->registerHelpers(); + $this->registerCommands(); + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register(): void + { + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register the helpers. + * + * @return void + */ + protected function registerHelpers(): void + { + foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) { + require_once $filename; + } + } + + /** + * Dynamically Register Commands. + * + * @return void + */ + public function registerCommands(): void + { + if (!$this->app->runningInConsole()) { + return; + } + $commandPath = __DIR__ . '/../Console'; + $commands = collect(File::allFiles($commandPath)) + ->map(function ($file) { + $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension(); + return $class; + }) + ->filter(function ($class) { + return class_exists($class); + }) + ->values() + ->all(); + $this->commands($commands); + } + + /** + * Register config. + * + * @return void + */ + protected function registerConfig(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'), + ], 'config'); + $this->mergeConfigFrom( + module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower() + ); + } + + /** + * Register views. + * + * @return void + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower()); + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $this->publishes([ + $sourcePath => $viewPath + ], ['views', ModuleInfo::nameLower() . '-module-views']); + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); + } + + /** + * Register translations. + * + * @return void + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); + } else { + $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); + } + } + + /** + * Regsiter View Paths + * @return array + */ + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (Config::get('view.paths') as $path) { + if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) { + $paths[] = $path . '/modules/' . ModuleInfo::nameLower(); + } + } + return $paths; + } +} diff --git a/Modules/ChatGPT/Providers/RouteServiceProvider.php b/Modules/ChatGPT/Providers/RouteServiceProvider.php new file mode 100644 index 00000000..e6b5df61 --- /dev/null +++ b/Modules/ChatGPT/Providers/RouteServiceProvider.php @@ -0,0 +1,69 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->moduleNamespace) + ->group(module_path('ChatGPT', '/Routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->moduleNamespace) + ->group(module_path('ChatGPT', '/Routes/api.php')); + } +} diff --git a/Modules/ChatGPT/Resources/assets/.gitkeep b/Modules/ChatGPT/Resources/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Resources/assets/js/app.js b/Modules/ChatGPT/Resources/assets/js/app.js new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Resources/assets/sass/app.scss b/Modules/ChatGPT/Resources/assets/sass/app.scss new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Resources/lang/.gitkeep b/Modules/ChatGPT/Resources/lang/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Resources/views/.gitkeep b/Modules/ChatGPT/Resources/views/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Resources/views/index.blade.php b/Modules/ChatGPT/Resources/views/index.blade.php new file mode 100644 index 00000000..bc25acc3 --- /dev/null +++ b/Modules/ChatGPT/Resources/views/index.blade.php @@ -0,0 +1,9 @@ +@extends('chatgpt::layouts.master') + +@section('content') +

Hello World

+ +

+ This view is loaded from module: {!! config('chatgpt.name') !!} +

+@endsection diff --git a/Modules/ChatGPT/Resources/views/layouts/master.blade.php b/Modules/ChatGPT/Resources/views/layouts/master.blade.php new file mode 100644 index 00000000..df3488c4 --- /dev/null +++ b/Modules/ChatGPT/Resources/views/layouts/master.blade.php @@ -0,0 +1,19 @@ + + + + + + + Module ChatGPT + + {{-- Laravel Mix - CSS File --}} + {{-- --}} + + + + @yield('content') + + {{-- Laravel Mix - JS File --}} + {{-- --}} + + diff --git a/Modules/ChatGPT/Routes/.gitkeep b/Modules/ChatGPT/Routes/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Routes/api.php b/Modules/ChatGPT/Routes/api.php new file mode 100644 index 00000000..2a02aa92 --- /dev/null +++ b/Modules/ChatGPT/Routes/api.php @@ -0,0 +1,18 @@ +get('/chatgpt', function (Request $request) { + return $request->user(); +}); \ No newline at end of file diff --git a/Modules/ChatGPT/Routes/web.php b/Modules/ChatGPT/Routes/web.php new file mode 100644 index 00000000..5006d946 --- /dev/null +++ b/Modules/ChatGPT/Routes/web.php @@ -0,0 +1,16 @@ +group(function() { + Route::get('/', 'ChatGPTController@index'); +}); diff --git a/Modules/ChatGPT/Support/.gitkeep b/Modules/ChatGPT/Support/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Support/ModuleInfo.php b/Modules/ChatGPT/Support/ModuleInfo.php new file mode 100644 index 00000000..64bc361b --- /dev/null +++ b/Modules/ChatGPT/Support/ModuleInfo.php @@ -0,0 +1,26 @@ + Date: Wed, 24 Jun 2026 09:56:13 +0100 Subject: [PATCH 03/58] new chatgpt module for api intergration --- .../Console/DeleteOpenAiFilesCommand.php | 63 +++++++ ...0001_create_chatgpt_api_settings_table.php | 30 ++++ .../Http/Controllers/ChatGPTController.php | 101 ++++++----- .../StoreChatGptApiSettingRequest.php | 30 ++++ .../UpdateChatGptApiSettingRequest.php | 30 ++++ Modules/ChatGPT/Models/ChatGptApiSetting.php | 53 ++++++ .../ChatGPT/Resources/views/index.blade.php | 170 +++++++++++++++++- Modules/ChatGPT/Routes/web.php | 24 ++- .../ChatGPT/Services/OpenAiFileService.php | 121 +++++++++++++ .../Tests/Feature/ChatGptApiSettingTest.php | 49 +++++ Modules/ChatGPT/changelog.yaml | 8 + Modules/ChatGPT/module.json | 2 +- Modules/ChatGPT/readme.md | 28 +++ Modules/Helpdesk/Config/config.php | 11 +- .../TicketAttachmentStoreController.php | 37 +++- .../TicketCommentStoreController.php | 37 +++- .../Controllers/TicketStoreController.php | 37 +++- Modules/Helpdesk/Livewire/TicketCreate.php | 39 +++- Modules/Helpdesk/Livewire/TicketEdit.php | 35 +++- Modules/Helpdesk/Livewire/TicketShow.php | 40 ++++- .../views/livewire/ticket-create.blade.php | 33 +++- .../views/livewire/ticket-edit.blade.php | 9 +- .../views/livewire/ticket-show.blade.php | 11 +- .../views/partials/comment-thread.blade.php | 9 +- .../Helpdesk/Services/AttachmentService.php | 63 ++++++- .../Tests/Feature/GitAndAttachmentTest.php | 35 ++++ Modules/Helpdesk/changelog.yaml | 28 +++ Modules/Helpdesk/module.json | 2 +- 28 files changed, 1028 insertions(+), 107 deletions(-) create mode 100644 Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php create mode 100644 Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php create mode 100644 Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php create mode 100644 Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php create mode 100644 Modules/ChatGPT/Models/ChatGptApiSetting.php create mode 100644 Modules/ChatGPT/Services/OpenAiFileService.php create mode 100644 Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php create mode 100644 Modules/ChatGPT/changelog.yaml diff --git a/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php b/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php new file mode 100644 index 00000000..e95ad36f --- /dev/null +++ b/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php @@ -0,0 +1,63 @@ +error('No active ChatGPT/OpenAI API key is stored.'); + 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; + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + return self::FAILURE; + } + } +} diff --git a/Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php b/Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php new file mode 100644 index 00000000..7380e84a --- /dev/null +++ b/Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('name')->default('OpenAI API Key'); + $table->text('api_key'); + $table->boolean('is_active')->default(true)->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('chatgpt_api_settings'); + } +}; diff --git a/Modules/ChatGPT/Http/Controllers/ChatGPTController.php b/Modules/ChatGPT/Http/Controllers/ChatGPTController.php index 726b6848..a207759e 100644 --- a/Modules/ChatGPT/Http/Controllers/ChatGPTController.php +++ b/Modules/ChatGPT/Http/Controllers/ChatGPTController.php @@ -3,77 +3,80 @@ namespace Modules\ChatGPT\Http\Controllers; use Illuminate\Contracts\Support\Renderable; -use Illuminate\Http\Request; +use Illuminate\Http\RedirectResponse; use Illuminate\Routing\Controller; +use Modules\ChatGPT\Http\Requests\StoreChatGptApiSettingRequest; +use Modules\ChatGPT\Http\Requests\UpdateChatGptApiSettingRequest; +use Modules\ChatGPT\Models\ChatGptApiSetting; class ChatGPTController extends Controller { /** - * Display a listing of the resource. - * @return Renderable + * Display the ChatGPT module settings page. */ - public function index() + public function index(): Renderable { - return view('chatgpt::index'); + return view('chatgpt::index', [ + 'settings' => ChatGptApiSetting::query()->latest('id')->get(), + ]); } /** - * Show the form for creating a new resource. - * @return Renderable + * Store a new encrypted OpenAI API key setting. */ - public function create() + public function store(StoreChatGptApiSettingRequest $request): RedirectResponse { - return view('chatgpt::create'); + $data = $request->validated(); + + ChatGptApiSetting::query()->update(['is_active' => false]); + + ChatGptApiSetting::query()->create([ + 'name' => $data['name'] ?: 'OpenAI API Key', + 'api_key' => $data['api_key'], + 'is_active' => (bool) ($data['is_active'] ?? true), + ]); + + return redirect() + ->route('chatgpt.index') + ->with('status', 'OpenAI API key saved.'); } /** - * Store a newly created resource in storage. - * @param Request $request - * @return Renderable + * Update an encrypted OpenAI API key setting. */ - public function store(Request $request) + public function update(UpdateChatGptApiSettingRequest $request, ChatGptApiSetting $setting): RedirectResponse { - // + $data = $request->validated(); + + if ((bool) ($data['is_active'] ?? false)) { + ChatGptApiSetting::query() + ->whereKeyNot($setting->getKey()) + ->update(['is_active' => false]); + } + + $setting->name = $data['name'] ?: $setting->name; + $setting->is_active = (bool) ($data['is_active'] ?? false); + + if (! empty($data['api_key'])) { + $setting->api_key = $data['api_key']; + } + + $setting->save(); + + return redirect() + ->route('chatgpt.index') + ->with('status', 'OpenAI API key updated.'); } /** - * Show the specified resource. - * @param int $id - * @return Renderable + * Delete an encrypted OpenAI API key setting. */ - public function show($id) + public function destroy(ChatGptApiSetting $setting): RedirectResponse { - return view('chatgpt::show'); - } + $setting->delete(); - /** - * Show the form for editing the specified resource. - * @param int $id - * @return Renderable - */ - public function edit($id) - { - return view('chatgpt::edit'); - } - - /** - * Update the specified resource in storage. - * @param Request $request - * @param int $id - * @return Renderable - */ - public function update(Request $request, $id) - { - // - } - - /** - * Remove the specified resource from storage. - * @param int $id - * @return Renderable - */ - public function destroy($id) - { - // + return redirect() + ->route('chatgpt.index') + ->with('status', 'OpenAI API key deleted.'); } } diff --git a/Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php b/Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php new file mode 100644 index 00000000..b57226ff --- /dev/null +++ b/Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php @@ -0,0 +1,30 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['nullable', 'string', 'max:255'], + 'api_key' => ['required', 'string', 'min:20', 'max:500'], + 'is_active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php b/Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php new file mode 100644 index 00000000..f1b8387a --- /dev/null +++ b/Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php @@ -0,0 +1,30 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['nullable', 'string', 'max:255'], + 'api_key' => ['nullable', 'string', 'min:20', 'max:500'], + 'is_active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/Modules/ChatGPT/Models/ChatGptApiSetting.php b/Modules/ChatGPT/Models/ChatGptApiSetting.php new file mode 100644 index 00000000..215a8320 --- /dev/null +++ b/Modules/ChatGPT/Models/ChatGptApiSetting.php @@ -0,0 +1,53 @@ + 'encrypted', + 'is_active' => 'boolean', + ]; + + protected $hidden = [ + 'api_key', + ]; + + /** + * Get the currently active OpenAI API setting. + */ + public static function active(): ?self + { + return self::query() + ->where('is_active', true) + ->latest('id') + ->first(); + } + + /** + * Return a masked version of the stored API key for display. + */ + public function maskedApiKey(): string + { + $key = (string) $this->api_key; + + if ($key === '') { + return 'Not set'; + } + + return substr($key, 0, 7) . '...' . substr($key, -4); + } +} diff --git a/Modules/ChatGPT/Resources/views/index.blade.php b/Modules/ChatGPT/Resources/views/index.blade.php index bc25acc3..83f2de0d 100644 --- a/Modules/ChatGPT/Resources/views/index.blade.php +++ b/Modules/ChatGPT/Resources/views/index.blade.php @@ -1,9 +1,171 @@ @extends('chatgpt::layouts.master') @section('content') -

Hello World

+
+
+
+
+

ChatGPT API Settings

+

+ Store the OpenAI API key used by the module command. Keys are encrypted at rest by Laravel. +

+
-

- This view is loaded from module: {!! config('chatgpt.name') !!} -

+ + php artisan chatgpt:delete-api-files + +
+
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if ($errors->any()) +
+ Unable to save the API key. +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+

Create API Key

+ + + @csrf + +
+ + +
+ +
+ + +
+ + + +
+ +
+ +
+ +
+

Stored API Keys

+ +
+ + + + + + + + + + + + @forelse ($settings as $setting) + + + + + + + + @empty + + + + @endforelse + +
NameKeyActiveUpdatedActions
+
+ @csrf + @method('PUT') + + + + + + +
+
{{ $setting->maskedApiKey() }} + @if ($setting->is_active) + Yes + @else + No + @endif + {{ $setting->updated_at?->format('d M Y H:i') }} +
+ + +
+ @csrf + @method('DELETE') + +
+
+
+ No API keys have been stored yet. +
+
+
+
@endsection diff --git a/Modules/ChatGPT/Routes/web.php b/Modules/ChatGPT/Routes/web.php index 5006d946..98f524e1 100644 --- a/Modules/ChatGPT/Routes/web.php +++ b/Modules/ChatGPT/Routes/web.php @@ -1,16 +1,14 @@ group(function() { - Route::get('/', 'ChatGPTController@index'); -}); +Route::middleware(['web', 'auth']) + ->prefix('chatgpt') + ->name('chatgpt.') + ->group(function (): void { + Route::get('/', [ChatGPTController::class, 'index'])->name('index'); + Route::post('/settings', [ChatGPTController::class, 'store'])->name('settings.store'); + Route::put('/settings/{setting}', [ChatGPTController::class, 'update'])->name('settings.update'); + Route::delete('/settings/{setting}', [ChatGPTController::class, 'destroy'])->name('settings.destroy'); + }); diff --git a/Modules/ChatGPT/Services/OpenAiFileService.php b/Modules/ChatGPT/Services/OpenAiFileService.php new file mode 100644 index 00000000..7077e44f --- /dev/null +++ b/Modules/ChatGPT/Services/OpenAiFileService.php @@ -0,0 +1,121 @@ +> + */ + public function listFiles(): array + { + $files = []; + $after = null; + + do { + $query = ['limit' => 100]; + + if (is_string($after) && $after !== '') { + $query['after'] = $after; + } + + $response = $this->client() + ->get(self::BASE_URL . '/files', $query); + + if ($response->failed()) { + throw new RuntimeException('OpenAI file list request failed: ' . $response->body()); + } + + $payload = $response->json(); + $batch = $payload['data'] ?? []; + + foreach ($batch as $file) { + if (is_array($file)) { + $files[] = $file; + } + } + + $hasMore = (bool) ($payload['has_more'] ?? false); + $after = $payload['last_id'] ?? null; + } while ($hasMore && is_string($after) && $after !== ''); + + return $files; + } + + /** + * Delete one OpenAI API file by ID. + * + * @return array + */ + public function deleteFile(string $fileId): array + { + $response = $this->client() + ->delete(self::BASE_URL . '/files/' . rawurlencode($fileId)); + + if ($response->failed()) { + throw new RuntimeException("OpenAI file delete request failed for {$fileId}: " . $response->body()); + } + + return $response->json() ?? []; + } + + /** + * Delete every OpenAI API file visible to the configured API key. + * + * @return array{listed:int, deleted:int, failed:int, failures:array} + */ + public function deleteAllFiles(): array + { + $files = $this->listFiles(); + $deleted = 0; + $failures = []; + + foreach ($files as $file) { + $fileId = (string) ($file['id'] ?? ''); + + if ($fileId === '') { + continue; + } + + try { + $this->deleteFile($fileId); + $deleted++; + } catch (RuntimeException $exception) { + $failures[] = [ + 'file_id' => $fileId, + 'error' => $exception->getMessage(), + ]; + } + } + + return [ + 'listed' => count($files), + 'deleted' => $deleted, + 'failed' => count($failures), + 'failures' => $failures, + ]; + } + + /** + * Build the authenticated OpenAI HTTP client. + */ + private function client(): PendingRequest + { + return Http::withToken($this->apiKey) + ->acceptJson() + ->timeout(60) + ->retry(2, 500); + } +} diff --git a/Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php b/Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php new file mode 100644 index 00000000..d29326b5 --- /dev/null +++ b/Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php @@ -0,0 +1,49 @@ +create([ + 'name' => 'OpenAI API Key', + 'api_key' => 'sk-test-value-that-is-long-enough', + 'is_active' => true, + ]); + + $raw = $setting->getRawOriginal('api_key'); + + $this->assertNotSame('sk-test-value-that-is-long-enough', $raw); + $this->assertSame('sk-test-value-that-is-long-enough', $setting->api_key); + } + + /** + * It resolves the latest active API key setting. + */ + public function test_it_resolves_latest_active_api_key_setting(): void + { + ChatGptApiSetting::query()->create([ + 'name' => 'Inactive', + 'api_key' => 'sk-inactive-value-that-is-long-enough', + 'is_active' => false, + ]); + + $active = ChatGptApiSetting::query()->create([ + 'name' => 'Active', + 'api_key' => 'sk-active-value-that-is-long-enough', + 'is_active' => true, + ]); + + $this->assertTrue($active->is(ChatGptApiSetting::active())); + } +} diff --git a/Modules/ChatGPT/changelog.yaml b/Modules/ChatGPT/changelog.yaml new file mode 100644 index 00000000..e6ef5636 --- /dev/null +++ b/Modules/ChatGPT/changelog.yaml @@ -0,0 +1,8 @@ +- version: 1.1.0 + date: 2026-06-24 + changes: + - Added encrypted database storage for OpenAI API keys. + - Added ChatGPT module settings UI to create, edit, activate, and delete stored API keys. + - Added chatgpt:delete-api-files command with dry-run support and no interactive confirmation. + - Added OpenAI files service for listing and deleting API files. + - Added feature tests for encrypted key storage and active key resolution. diff --git a/Modules/ChatGPT/module.json b/Modules/ChatGPT/module.json index 9b76c95d..f547c4b5 100644 --- a/Modules/ChatGPT/module.json +++ b/Modules/ChatGPT/module.json @@ -1,7 +1,7 @@ { "name": "ChatGPT", "alias": "chatgpt", - "version": "1.0.0", + "version": "1.1.0", "description": "", "keywords": [], "priority": 0, diff --git a/Modules/ChatGPT/readme.md b/Modules/ChatGPT/readme.md index e69de29b..fd961169 100644 --- a/Modules/ChatGPT/readme.md +++ b/Modules/ChatGPT/readme.md @@ -0,0 +1,28 @@ +# ChatGPT Module + +Provides encrypted OpenAI API key storage and an Artisan command for deleting all OpenAI API files visible to the active stored key. + +## Routes + +- `GET /chatgpt` - manage stored API keys. +- `POST /chatgpt/settings` - create a stored API key. +- `PUT /chatgpt/settings/{setting}` - update an API key setting. +- `DELETE /chatgpt/settings/{setting}` - delete an API key setting. + +## Commands + +Preview files without deleting: + +```bash +php artisan chatgpt:delete-api-files --dry-run +``` + +Delete all OpenAI API files without interactive confirmation: + +```bash +php artisan chatgpt:delete-api-files +``` + +## Security + +API keys are encrypted at rest using Laravel's encrypted Eloquent cast and the application `APP_KEY`. diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php index 521e7582..c5922cfa 100644 --- a/Modules/Helpdesk/Config/config.php +++ b/Modules/Helpdesk/Config/config.php @@ -7,7 +7,16 @@ return [ 'middleware' => ['web', 'auth'], 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'), 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), - 'allowed_mimes' => ['md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'], + 'allowed_extensions' => [ + 'md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'log', 'zip', + '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', + ], + + // Backwards-compatible alias for older module code/tests that still read this key. + 'allowed_mimes' => [], 'default_status_slug' => 'new', 'closed_status_slug' => 'closed', ]; diff --git a/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php index d963ef43..05ace4c5 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php @@ -22,12 +22,9 @@ final class TicketAttachmentStoreController extends Controller { Gate::authorize('update', $ticket); - $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240); - $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']); - $request->validate([ 'attachments' => ['required', 'array', 'min:1'], - 'attachments.*' => ['required', 'file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)], + 'attachments.*' => $this->attachmentFileRules(required: true), ]); $user = Auth::user(); @@ -43,4 +40,36 @@ final class TicketAttachmentStoreController extends Controller ->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 + */ + 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 + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php index c3ba3d2b..4026c47a 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php @@ -29,9 +29,6 @@ final class TicketCommentStoreController extends Controller { Gate::authorize('comment', $ticket); - $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240); - $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']); - $validated = $request->validate([ 'subject' => ['nullable', 'string', 'max:180'], 'commentBody' => ['nullable', 'string'], @@ -43,7 +40,7 @@ final class TicketCommentStoreController extends Controller ], 'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')], 'attachments' => ['nullable', 'array'], - 'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)], + 'attachments.*' => $this->attachmentFileRules(), ]); $user = Auth::user(); @@ -168,4 +165,36 @@ final class TicketCommentStoreController extends Controller || 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 + */ + 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 + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Http/Controllers/TicketStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketStoreController.php index 22acdebe..6313bcbd 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketStoreController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketStoreController.php @@ -21,9 +21,6 @@ final class TicketStoreController extends Controller */ public function __invoke(Request $request, TicketService $tickets): RedirectResponse { - $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240); - $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx']); - $validated = $request->validate([ 'company_id' => ['nullable', 'integer'], 'subject' => ['required', 'string', 'max:200'], @@ -35,7 +32,7 @@ final class TicketStoreController extends Controller 'team_id' => ['nullable', 'integer', Rule::exists('helpdesk_teams', 'id')], 'due_at' => ['nullable', 'date'], 'attachments' => ['nullable', 'array'], - 'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)], + 'attachments.*' => $this->attachmentFileRules(), ]); $user = Auth::user(); @@ -61,4 +58,36 @@ final class TicketStoreController extends Controller ->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 + */ + 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 + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php index 6256dbc3..3cbb635e 100644 --- a/Modules/Helpdesk/Livewire/TicketCreate.php +++ b/Modules/Helpdesk/Livewire/TicketCreate.php @@ -74,11 +74,7 @@ final class TicketCreate extends Component 'tag_ids' => ['array'], 'tag_ids.*' => ['integer', 'exists:helpdesk_tags,id'], 'attachments' => ['array'], - 'attachments.*' => [ - 'file', - 'max:' . config('helpdesk.max_upload_kb'), - 'mimes:' . implode(',', config('helpdesk.allowed_mimes')), - ], + 'attachments.*' => $this->attachmentFileRules(), ]; } @@ -318,4 +314,37 @@ final class TicketCreate extends Component { return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default'); } + + /** + * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class. + * + * Laravel validates source-code attachments by extension because browsers often report + * PHP, JavaScript, RPG and other source files as text/plain or octet-stream. + * + * @return array + */ + private function attachmentFileRules(): array + { + return [ + 'file', + 'max:' . (int) config('helpdesk.max_upload_kb', 10240), + 'extensions:' . implode(',', $this->allowedAttachmentExtensions()), + ]; + } + + /** + * Return lower-case Helpdesk attachment extensions. + * + * @return array + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php index be75f718..8b0f7c58 100644 --- a/Modules/Helpdesk/Livewire/TicketEdit.php +++ b/Modules/Helpdesk/Livewire/TicketEdit.php @@ -109,7 +109,7 @@ final class TicketEdit extends Component $this->validate(); $this->validate([ 'attachments' => 'array', - 'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb', 10240) . '|mimes:' . implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])), + 'attachments.*' => $this->attachmentFileRules(), 'tag_ids.*' => 'integer|exists:helpdesk_tags,id', ]); @@ -317,4 +317,37 @@ final class TicketEdit extends Component return $payload; } + + /** + * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class. + * + * Laravel validates source-code attachments by extension because browsers often report + * PHP, JavaScript, RPG and other source files as text/plain or octet-stream. + * + * @return array + */ + private function attachmentFileRules(): array + { + return [ + 'file', + 'max:' . (int) config('helpdesk.max_upload_kb', 10240), + 'extensions:' . implode(',', $this->allowedAttachmentExtensions()), + ]; + } + + /** + * Return lower-case Helpdesk attachment extensions. + * + * @return array + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index bd3428f9..2e05558b 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -92,7 +92,7 @@ final class TicketShow extends Component $this->validate([ 'commentBody' => ['required', 'string', 'min:2'], - 'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb', 10240) . '|mimes:' . implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])), + 'attachments.*' => $this->attachmentFileRules(), ]); $tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments); @@ -299,12 +299,9 @@ final class TicketShow extends Component { $this->authorize('comment', $this->ticket); - $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240); - $allowedMimes = implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])); - $this->validate([ 'supportingFiles' => ['required', 'array', 'min:1'], - 'supportingFiles.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . $allowedMimes], + 'supportingFiles.*' => $this->attachmentFileRules(), ]); $user = Auth::user(); @@ -673,4 +670,37 @@ final class TicketShow extends Component return $comment; })->values(); } + + /** + * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class. + * + * Laravel validates source-code attachments by extension because browsers often report + * PHP, JavaScript, RPG and other source files as text/plain or octet-stream. + * + * @return array + */ + private function attachmentFileRules(): array + { + return [ + 'file', + 'max:' . (int) config('helpdesk.max_upload_kb', 10240), + 'extensions:' . implode(',', $this->allowedAttachmentExtensions()), + ]; + } + + /** + * Return lower-case Helpdesk attachment extensions. + * + * @return array + */ + 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(); + } + } diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index 32b2db16..85ebbfd4 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -1,3 +1,10 @@ +@php + $helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', []))) + ->filter(fn ($extension) => is_string($extension) && trim($extension) !== '') + ->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.'))) + ->unique() + ->implode(','); +@endphp
@vite(['resources/css/app.css', 'resources/js/app.js']) @include('helpdesk::partials.page-header', [ @@ -7,7 +14,24 @@ 'Raise a new support request with priority, product/service, project, category, team and full details.', ]) -
+

Ticket details

@@ -254,7 +278,7 @@
- +
@@ -289,9 +313,10 @@
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php index cd405b01..e74fb070 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php @@ -1,3 +1,10 @@ +@php + $helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', []))) + ->filter(fn ($extension) => is_string($extension) && trim($extension) !== '') + ->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.'))) + ->unique() + ->implode(','); +@endphp
@vite(['resources/css/app.css', 'resources/js/app.js']) @include('helpdesk::partials.page-header', [ @@ -323,7 +330,7 @@
-
filter(fn ($extension) => is_string($extension) && trim($extension) !== '') + ->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.'))) + ->unique() + ->implode(','); +@endphp @php use Illuminate\Support\Str; use Modules\Helpdesk\Models\TicketTimeLog; @@ -141,7 +148,7 @@ class="hd-attachment-action !rounded-lg !px-3 !py-2 !text-xs"> Choose files - @@ -456,7 +463,7 @@ class="inline-flex cursor-pointer items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-blue-700 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-400/70"> Choose files -
diff --git a/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php b/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php index 831bec7d..12d08920 100644 --- a/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php +++ b/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php @@ -1,3 +1,10 @@ +@php + $helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', []))) + ->filter(fn ($extension) => is_string($extension) && trim($extension) !== '') + ->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.'))) + ->unique() + ->implode(','); +@endphp @props(['comments', 'level' => 0, 'ticket' => null, 'canSeeInternalNotes' => false])
@@ -285,7 +292,7 @@
diff --git a/Modules/Helpdesk/Services/AttachmentService.php b/Modules/Helpdesk/Services/AttachmentService.php index 46320ad4..db117d95 100644 --- a/Modules/Helpdesk/Services/AttachmentService.php +++ b/Modules/Helpdesk/Services/AttachmentService.php @@ -6,27 +6,40 @@ namespace Modules\Helpdesk\Services; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Modules\Helpdesk\Models\Attachment; use Modules\Helpdesk\Models\Comment; use Modules\Helpdesk\Models\Ticket; final class AttachmentService { + /** + * Store multiple Helpdesk attachments against a ticket or comment. + * + * Livewire create/comment forms pass TemporaryUploadedFile instances, while + * controller endpoints pass Symfony/Laravel UploadedFile instances. Both + * are supported here so uploads are never silently skipped on ticket create. + * + * @param array $files + */ public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void { foreach ($files as $file) { - if (! $file instanceof UploadedFile || ! $file->isValid()) { + if (! $this->isSupportedUpload($file)) { continue; } + $disk = (string) config('helpdesk.disk', 'public'); - - $originalName = $file->getClientOriginalName(); - $mimeType = $file->getClientMimeType(); - $size = (int) ($file->getSize() ?: 0); - + $originalName = $this->originalName($file); + $mimeType = $this->mimeType($file); + $size = $this->size($file); $path = $file->store('tickets/' . $ticket->id, $disk); - if ($size === 0 && $path !== false) { + if (! is_string($path) || $path === '') { + continue; + } + + if ($size === 0) { $size = (int) Storage::disk($disk)->size($path); } @@ -51,4 +64,40 @@ final class AttachmentService Storage::disk($attachment->disk)->delete($attachment->path); $attachment->delete(); } + + /** + * Determine whether the value is a storeable uploaded file. + */ + private function isSupportedUpload(mixed $file): bool + { + if ($file instanceof TemporaryUploadedFile) { + return true; + } + + return $file instanceof UploadedFile && $file->isValid(); + } + + /** + * Return the uploaded file's original client filename. + */ + private function originalName(mixed $file): string + { + return (string) $file->getClientOriginalName(); + } + + /** + * Return the uploaded file's reported mime type. + */ + private function mimeType(mixed $file): string + { + return (string) ($file->getClientMimeType() ?: $file->getMimeType() ?: 'application/octet-stream'); + } + + /** + * Return the uploaded file's byte size. + */ + private function size(mixed $file): int + { + return (int) ($file->getSize() ?: 0); + } } diff --git a/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php b/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php index fb05d998..6ee4833e 100644 --- a/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php +++ b/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php @@ -76,6 +76,41 @@ final class GitAndAttachmentTest extends TestCase ]); } + + public function test_existing_ticket_supporting_files_route_accepts_source_code_attachments(): void + { + Storage::fake('local'); + config()->set('helpdesk.disk', 'local'); + + $user = $this->admin(); + $ticket = $this->ticket(['owner' => $user]); + + $this->actingAs($user) + ->post(route('helpdesk.tickets.attachments.store', $ticket), [ + 'attachments' => [ + UploadedFile::fake()->create('TicketStoreController.php', 4, 'text/plain'), + UploadedFile::fake()->create('ticket-editor.js', 4, 'text/plain'), + UploadedFile::fake()->create('warehouse-sync.rpgle', 4, 'text/plain'), + ], + ]) + ->assertRedirect(route('helpdesk.tickets.show', $ticket)); + + $this->assertDatabaseHas('helpdesk_attachments', [ + 'ticket_id' => $ticket->id, + 'original_name' => 'TicketStoreController.php', + ]); + + $this->assertDatabaseHas('helpdesk_attachments', [ + 'ticket_id' => $ticket->id, + 'original_name' => 'ticket-editor.js', + ]); + + $this->assertDatabaseHas('helpdesk_attachments', [ + 'ticket_id' => $ticket->id, + 'original_name' => 'warehouse-sync.rpgle', + ]); + } + public function test_attachment_service_stores_and_deletes_ticket_files(): void { Storage::fake('local'); diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 5cdd517c..10d3ac30 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -236,3 +236,31 @@ - Restored native browser spellcheck on the contenteditable editor by allowing typed text to remain stable in the DOM. - Added submit-capture body synchronisation before validation so tickets can be created reliably. - Added regression assertions for editor spellcheck, keypress sync prevention, and submit-time body sync. + +- version: 1.9.53 + date: 2026-06-24 + type: feature + summary: Allow Helpdesk ticket attachments to include source-code files. + changes: + - Added central Helpdesk attachment validation based on allowed filename extensions rather than MIME guesses so PHP, JavaScript, RPG and other source files can be uploaded reliably. + - Added a broader source-file extension allow-list covering PHP, JS/TS, Vue, CSS, HTML, SQL, shell scripts, Python, Java, C/C++, RPG/RPGLE, CL/CLLE, COBOL and common configuration files. + - Updated ticket create, ticket edit, ticket comments and supporting-file upload validation to use the shared attachment rules. + - Added source-file accept hints to Helpdesk file inputs. + - Added regression coverage for PHP, JavaScript and RPG source-file attachment support. +- version: 1.9.54 + date: 2026-06-24 + changes: + - Removed the new AttachmentValidation support class dependency so source file attachment validation cannot fail when Composer optimized autoload has not been regenerated. + - Kept source-code attachment support using Laravel extension validation for PHP, JavaScript, RPG/RPGLE and other source/config files. + - Updated ticket create/edit/show and attachment controllers to use existing-class validation helpers. + +- version: 1.9.55 + date: 2026-06-24 + type: bugfix + summary: Persist attachments when creating Helpdesk tickets. + changes: + - Prevented ticket create submission while Livewire file uploads are still in progress so tickets cannot be saved before attachments reach the component. + - Disabled the create button during both attachment upload and ticket save operations. + - Hardened the attachment service to explicitly support Livewire temporary uploads as well as controller uploaded files. + - Added regression coverage for create-form upload guarding and Livewire temporary upload support. + - Preserved append-only changelog history. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 5d826b68..6ffe8c63 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.52" + "version": "1.9.55" } From c44b362710f65b5c4b12658a1f75979936b97470 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 24 Jun 2026 10:40:19 +0100 Subject: [PATCH 04/58] new ataxchments preview code --- Modules/Helpdesk/Config/config.php | 9 + ...elpdesk_attachment_experience_metadata.php | 64 ++++++ .../AttachmentDeleteController.php | 36 +++ .../AttachmentDownloadController.php | 148 ++++++++++++ .../AttachmentPreviewController.php | 50 ++++ Modules/Helpdesk/Models/Attachment.php | 215 +++++++++++++++++- .../attachments/source-preview.blade.php | 60 +++++ .../views/components/html-editor.blade.php | 21 +- .../views/livewire/ticket-create.blade.php | 103 ++++++++- .../views/livewire/ticket-show.blade.php | 82 +++++-- Modules/Helpdesk/Routes/web.php | 6 + .../Helpdesk/Services/AttachmentService.php | 105 ++++++++- .../TicketAttachmentExperienceFilesTest.php | 68 ++++++ .../TicketCreateAttachmentPersistenceTest.php | 43 ++++ .../TicketSourceAttachmentFilesTest.php | 50 ++++ Modules/Helpdesk/changelog.yaml | 52 +++++ Modules/Helpdesk/module.json | 2 +- 17 files changed, 1071 insertions(+), 43 deletions(-) create mode 100644 Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php create mode 100644 Modules/Helpdesk/Http/Controllers/AttachmentDeleteController.php create mode 100644 Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php create mode 100644 Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketAttachmentExperienceFilesTest.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentPersistenceTest.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketSourceAttachmentFilesTest.php diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php index c5922cfa..d09cb6b7 100644 --- a/Modules/Helpdesk/Config/config.php +++ b/Modules/Helpdesk/Config/config.php @@ -15,6 +15,15 @@ return [ 'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', ], + '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', + ], + 'blocked_extensions' => ['exe', 'dll', 'com', 'scr', 'msi', 'app', 'dmg', 'pkg', 'jar', 'war'], + 'virus_scanner' => env('HELPDESK_VIRUS_SCANNER', null), + // Backwards-compatible alias for older module code/tests that still read this key. 'allowed_mimes' => [], 'default_status_slug' => 'new', diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php b/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php new file mode 100644 index 00000000..4f549bc3 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php @@ -0,0 +1,64 @@ +string('extension', 32)->nullable()->after('original_name')->index(); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'category')) { + $table->string('category', 32)->nullable()->after('extension')->index(); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'checksum_sha256')) { + $table->string('checksum_sha256', 64)->nullable()->after('size')->index(); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'download_count')) { + $table->unsignedInteger('download_count')->default(0)->after('checksum_sha256'); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) { + $table->timestamp('last_downloaded_at')->nullable()->after('download_count'); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'deleted_at')) { + $table->timestamp('deleted_at')->nullable()->after('last_downloaded_at')->index(); + } + + if (! Schema::hasColumn('helpdesk_attachments', 'deleted_by')) { + $table->foreignId('deleted_by')->nullable()->after('deleted_at')->constrained('users')->nullOnDelete(); + } + }); + } + + /** + * Remove attachment experience metadata. + */ + public function down(): void + { + Schema::table('helpdesk_attachments', static function (Blueprint $table): void { + if (Schema::hasColumn('helpdesk_attachments', 'deleted_by')) { + $table->dropConstrainedForeignId('deleted_by'); + } + + foreach (['deleted_at', 'last_downloaded_at', 'download_count', 'checksum_sha256', 'category', 'extension'] as $column) { + if (Schema::hasColumn('helpdesk_attachments', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/Modules/Helpdesk/Http/Controllers/AttachmentDeleteController.php b/Modules/Helpdesk/Http/Controllers/AttachmentDeleteController.php new file mode 100644 index 00000000..c7553420 --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/AttachmentDeleteController.php @@ -0,0 +1,36 @@ +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.'); + } +} diff --git a/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php b/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php index 37446b50..b21cb4d6 100644 --- a/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php +++ b/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php @@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Http\Controllers; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Http\RedirectResponse; +use Illuminate\View\View; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; @@ -49,6 +50,153 @@ final class AttachmentDownloadController extends Controller 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', + }; + } + } diff --git a/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php b/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php new file mode 100644 index 00000000..02993a9b --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php @@ -0,0 +1,50 @@ +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', + ]); + } +} diff --git a/Modules/Helpdesk/Models/Attachment.php b/Modules/Helpdesk/Models/Attachment.php index 7f6b86fa..77c3faea 100644 --- a/Modules/Helpdesk/Models/Attachment.php +++ b/Modules/Helpdesk/Models/Attachment.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; final class Attachment extends Model { @@ -17,10 +18,31 @@ final class Attachment extends Model protected $table = 'helpdesk_attachments'; /** @var list */ - protected $fillable = ['ticket_id', 'comment_id', 'user_id', 'disk', 'path', 'original_name', 'mime_type', 'size']; + protected $fillable = [ + 'ticket_id', + 'comment_id', + 'user_id', + 'disk', + 'path', + 'original_name', + 'mime_type', + 'size', + 'extension', + 'category', + 'checksum_sha256', + 'download_count', + 'last_downloaded_at', + 'deleted_at', + 'deleted_by', + ]; /** @var array */ - protected $casts = ['size' => 'integer']; + protected $casts = [ + 'size' => 'integer', + 'download_count' => 'integer', + 'last_downloaded_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; /** @return BelongsTo */ public function ticket(): BelongsTo @@ -40,8 +62,195 @@ final class Attachment extends Model return $this->belongsTo(User::class, 'user_id'); } + /** + * Return an attribute only when it was actually selected from the database. + * + * Laravel 13 can throw when accessing missing attributes. The Helpdesk + * attachment metadata columns are introduced by an optional forward + * migration, so model helpers must remain safe before that migration has + * been applied. + */ + private function loadedAttribute(string $key): mixed + { + $attributes = $this->getAttributes(); + + return array_key_exists($key, $attributes) ? $this->getAttributeValue($key) : null; + } + + /** + * Return the original client filename with compatibility fallbacks. + */ + public function displayName(): string + { + $name = $this->loadedAttribute('original_name') + ?? $this->loadedAttribute('original_filename') + ?? $this->loadedAttribute('filename') + ?? $this->loadedAttribute('file_name'); + + if (is_string($name) && $name !== '') { + return $name; + } + + return basename((string) ($this->loadedAttribute('path') ?? $this->loadedAttribute('file_path') ?? 'attachment')); + } + + /** + * Return the stored byte size, falling back to legacy names. + */ + public function byteSize(): int + { + return (int) ($this->loadedAttribute('size') ?? $this->loadedAttribute('size_bytes') ?? 0); + } + + /** + * Return the safe download count when the column exists. + */ + public function downloadCount(): int + { + return (int) ($this->loadedAttribute('download_count') ?? 0); + } + + /** + * Determine whether the attachment has been soft deleted. + */ + public function isDeletedAttachment(): bool + { + return filled($this->loadedAttribute('deleted_at')); + } + + /** + * Return the lower-case file extension from stored metadata or filename. + */ + public function extension(): string + { + $extension = $this->loadedAttribute('extension'); + + if (! is_string($extension) || $extension === '') { + $extension = pathinfo($this->displayName(), PATHINFO_EXTENSION); + } + + return strtolower(ltrim((string) $extension, '.')); + } + + /** + * Return the display category used by Helpdesk attachment cards. + */ + public function category(): string + { + $category = $this->loadedAttribute('category'); + + if (is_string($category) && $category !== '') { + return $category; + } + + return match (true) { + $this->isImage() => 'image', + $this->isPdf() => 'pdf', + $this->isArchive() => 'archive', + $this->isSourceCode() => 'source', + default => 'file', + }; + } + + /** + * Determine whether the attachment is a browser-previewable image. + */ + public function isImage(): bool + { + return in_array($this->extension(), ['png', 'jpg', 'jpeg', 'webp', 'gif'], true) + || Str::startsWith((string) $this->loadedAttribute('mime_type'), 'image/'); + } + + /** + * Determine whether the attachment is a PDF document. + */ + public function isPdf(): bool + { + return $this->extension() === 'pdf' || $this->loadedAttribute('mime_type') === 'application/pdf'; + } + + /** + * Determine whether the attachment is an archive. + */ + public function isArchive(): bool + { + return in_array($this->extension(), ['zip', 'tar', 'gz', '7z', 'rar'], true); + } + + /** + * Determine whether the attachment is source/configuration text safe for the read-only viewer. + */ + public function isSourceCode(): bool + { + return in_array($this->extension(), [ + 'php', 'phtml', 'phps', 'blade.php', '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); + } + + /** + * Determine whether the attachment can be streamed inline by the preview route. + */ + public function isPreviewableBinary(): bool + { + return $this->isImage() || $this->isPdf(); + } + + /** + * Determine whether the Helpdesk UI can show an inline preview or viewer link. + */ + public function canPreview(): bool + { + return $this->isSourceCode() || $this->isPreviewableBinary(); + } + + /** + * Return a Font Awesome icon for the attachment type. + */ + public function icon(): string + { + return match ($this->category()) { + 'image' => 'fa-regular fa-file-image', + 'pdf' => 'fa-regular fa-file-pdf', + 'source' => 'fa-solid fa-code', + 'archive' => 'fa-regular fa-file-zipper', + default => 'fa-regular fa-file-lines', + }; + } + + /** + * Return the syntax language key used by the source viewer. + */ + public function language(): string + { + return match ($this->extension()) { + 'php', 'phtml', 'phps', 'blade.php' => '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', + }; + } + + public function url(): string { - return Storage::disk($this->disk)->url($this->path); + return Storage::disk((string) $this->loadedAttribute('disk'))->url((string) $this->loadedAttribute('path')); } } diff --git a/Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php b/Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php new file mode 100644 index 00000000..57c7d365 --- /dev/null +++ b/Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php @@ -0,0 +1,60 @@ +@php + $lines = preg_split('/\r\n|\r|\n/', $contents); + $downloadUrl = route('helpdesk.attachments.download', $attachment->id); +@endphp + +@extends('helpdesk::layouts.master') + +@section('content') +
+
+
+
+
+

Read-only source viewer

+

{{ $attachment->original_name ?? 'Attachment' }}

+

+ Language: {{ strtoupper($language) }} + • + Size: {{ Number::fileSize($attachment->size) }} +

+
+ +
+ + + Download + + +
+
+
+ +
+
+ + + @foreach ($lines as $index => $line) + + + + + @endforeach + +
{{ $index + 1 }}{{ $line }}
+
+
+
+
+ + +@endsection diff --git a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php index 6e8a4218..66cbd03b 100644 --- a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php +++ b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php @@ -39,6 +39,21 @@ document.execCommand(action, false, value); this.syncToHiddenInput(); }, + insertCodeBlock() { + this.$refs.editor.focus(); + document.execCommand('formatBlock', false, 'pre'); + this.syncToHiddenInput(); + }, + handlePaste(event) { + const files = Array.from(event.clipboardData?.files || []); + const images = files.filter((file) => file.type.startsWith('image/')); + + if (images.length > 0) { + window.dispatchEvent(new CustomEvent('helpdesk-editor-files-pasted', { detail: { files: images } })); + } + + this.$nextTick(() => this.syncToHiddenInput()); + }, clear() { this.value = ''; this.$refs.editor.innerHTML = ''; @@ -80,6 +95,8 @@ class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">1. List +

- Browser spellcheck is enabled for this rich editor. Misspelled words are checked by the browser/operating system while typing. + Browser spellcheck is enabled. Use Code for snippets, and paste screenshots directly into the editor to attach them to the ticket.

@if ($model) diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index 85ebbfd4..9b8faa9c 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -259,7 +259,16 @@
-
+

- Add screenshots, documents, logs or supporting files to help resolve the ticket. + Drag and drop screenshots, PDFs, logs, source files or documents. PHP, JS, RPG/RPGLE, CLLE and other source files are accepted.

@@ -278,12 +287,38 @@
- + -
- - Uploading attachments... + + + + +
+
+ Uploading attachments... + +
+
+
+
@error('attachments') @@ -295,8 +330,6 @@ @enderror
-
-
-