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/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/.gitkeep b/Modules/ChatGPT/Database/Migrations/.gitkeep new file mode 100644 index 00000000..e69de29b 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/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 @@ + ChatGptApiSetting::query()->latest('id')->get(), + ]); + } + + /** + * Store a new encrypted OpenAI API key setting. + */ + public function store(StoreChatGptApiSettingRequest $request): RedirectResponse + { + $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.'); + } + + /** + * Update an encrypted OpenAI API key setting. + */ + 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.'); + } + + /** + * Delete an encrypted OpenAI API key setting. + */ + public function destroy(ChatGptApiSetting $setting): RedirectResponse + { + $setting->delete(); + + return redirect() + ->route('chatgpt.index') + ->with('status', 'OpenAI API key deleted.'); + } +} diff --git a/Modules/ChatGPT/Http/Middleware/.gitkeep b/Modules/ChatGPT/Http/Middleware/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Http/Requests/.gitkeep b/Modules/ChatGPT/Http/Requests/.gitkeep new file mode 100644 index 00000000..e69de29b 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/.gitkeep b/Modules/ChatGPT/Models/.gitkeep new file mode 100644 index 00000000..e69de29b 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/Providers/.gitkeep b/Modules/ChatGPT/Providers/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Providers/ChatGPTServiceProvider.php b/Modules/ChatGPT/Providers/ChatGPTServiceProvider.php new file mode 100644 index 00000000..b5ea1255 --- /dev/null +++ b/Modules/ChatGPT/Providers/ChatGPTServiceProvider.php @@ -0,0 +1,155 @@ +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..83f2de0d --- /dev/null +++ b/Modules/ChatGPT/Resources/views/index.blade.php @@ -0,0 +1,171 @@ +@extends('chatgpt::layouts.master') + +@section('content') +
+
+
+
+

ChatGPT API Settings

+

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

+
+ + + php artisan chatgpt:delete-api-files + +
+
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if ($errors->any()) +
+ Unable to save the API key. + +
+ @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/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..98f524e1 --- /dev/null +++ b/Modules/ChatGPT/Routes/web.php @@ -0,0 +1,14 @@ +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/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 @@ +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/Tests/Unit/.gitkeep b/Modules/ChatGPT/Tests/Unit/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/Traits/.gitkeep b/Modules/ChatGPT/Traits/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/ChatGPT/View/.gitkeep b/Modules/ChatGPT/View/.gitkeep new file mode 100644 index 00000000..e69de29b 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/composer.json b/Modules/ChatGPT/composer.json new file mode 100644 index 00000000..05da6929 --- /dev/null +++ b/Modules/ChatGPT/composer.json @@ -0,0 +1,29 @@ +{ + "name": "mpba/chatgpt", + "description": "", + "authors": [ + { + "name": "Mark Gregory", + "email": "mark.gregory@mpba.co.uk" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": { + + } + } + }, + "autoload": { + "psr-4": { + "Modules\\ChatGPT\\": "" + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/", + "Modules\\ChatGPT\\Tests\\": "modules/ChatGPT/Tests/" + } + } + } +} diff --git a/Modules/ChatGPT/module.json b/Modules/ChatGPT/module.json new file mode 100644 index 00000000..f547c4b5 --- /dev/null +++ b/Modules/ChatGPT/module.json @@ -0,0 +1,14 @@ +{ + "name": "ChatGPT", + "alias": "chatgpt", + "version": "1.1.0", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\ChatGPT\\Providers\\ChatGPTServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/Modules/ChatGPT/package.json b/Modules/ChatGPT/package.json new file mode 100644 index 00000000..4599509f --- /dev/null +++ b/Modules/ChatGPT/package.json @@ -0,0 +1,17 @@ +{ + "private": true, + "scripts": { + "dev": "npm run development", + "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", + "watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", + "watch-poll": "npm run watch -- --watch-poll", + "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", + "prod": "npm run production", + "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" + }, + "devDependencies": { + "cross-env": "^7.0", + "laravel-mix": "^5.0.1", + "laravel-mix-merge-manifest": "^0.1.2" + } +} diff --git a/Modules/ChatGPT/readme.md b/Modules/ChatGPT/readme.md new file mode 100644 index 00000000..fd961169 --- /dev/null +++ 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/ChatGPT/webpack.mix.js b/Modules/ChatGPT/webpack.mix.js new file mode 100644 index 00000000..3989bd3e --- /dev/null +++ b/Modules/ChatGPT/webpack.mix.js @@ -0,0 +1,11 @@ +const dotenvExpand = require('dotenv-expand'); +dotenvExpand(require('dotenv').config({ path: '../../.env'/*, debug: true*/})); + +const mix = require('laravel-mix'); + +mix.js(__dirname + '/Resources/assets/js/app.js', 'js/chatgpt.js') + .sass( __dirname + '/Resources/assets/sass/app.scss', 'css/chatgpt.css'); + +if (mix.inProduction()) { + mix.version(); +} diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php index 521e7582..d09cb6b7 100644 --- a/Modules/Helpdesk/Config/config.php +++ b/Modules/Helpdesk/Config/config.php @@ -7,7 +7,25 @@ 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', + ], + + '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', 'closed_status_slug' => 'closed', ]; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_17_123000_add_deletion_audit_fields_to_helpdesk_comments.php b/Modules/Helpdesk/Database/Migrations/2026_06_17_123000_add_deletion_audit_fields_to_helpdesk_comments.php index 9436569a..2c9a54b1 100644 --- a/Modules/Helpdesk/Database/Migrations/2026_06_17_123000_add_deletion_audit_fields_to_helpdesk_comments.php +++ b/Modules/Helpdesk/Database/Migrations/2026_06_17_123000_add_deletion_audit_fields_to_helpdesk_comments.php @@ -11,6 +11,10 @@ return new class extends Migration public function up(): void { Schema::table('helpdesk_comments', static function (Blueprint $table): void { + if (! Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) { + $table->timestamp('tombstoned_at')->nullable()->after('synced_at'); + } + if (! Schema::hasColumn('helpdesk_comments', 'deleted_by')) { $table->foreignId('deleted_by')->nullable()->after('tombstoned_at')->constrained('users')->nullOnDelete(); } 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/Helpdesk_GitHub_Gitea_Integration_Guide.md b/Modules/Helpdesk/Helpdesk_GitHub_Gitea_Integration_Guide.md new file mode 100644 index 00000000..4cff5730 --- /dev/null +++ b/Modules/Helpdesk/Helpdesk_GitHub_Gitea_Integration_Guide.md @@ -0,0 +1,225 @@ +# Helpdesk Git Integration Guide + +## Gitea & GitHub Integration, Ticket Linking, Development Workflow, and Deployment Standards + +Version: 1.0 +Module: Helpdesk +Platform: WMS Core +Last Updated: June 2026 + +## Overview + +The Helpdesk Git Integration allows tickets to be linked directly to commits, branches, pull requests, merges, repository activity, and deployments. + +### Ticket Reference Format + +```text +HD-YYYY-NNNNN +``` + +Example: + +```text +HD-2026-00012 +``` + +## Automatic Linking Rules + +The integration searches for ticket references in: + +- Commit messages +- Branch names +- Pull request titles +- Merge commits + +### Example Commit + +```text +HD-2026-00012 Fix ticket-level Git commit display +``` + +### Example Branch + +```text +bugfix/HD-2026-00012-fix-ticket-display +``` + +## GitFlow Branch Standards + +### Feature + +```text +feature/HD-2026-00012-add-sla-dashboard +``` + +### Bugfix + +```text +bugfix/HD-2026-00012-fix-ticket-display +``` + +### Hotfix + +```text +hotfix/HD-2026-00012-production-fix +``` + +## Commit Standards + +Always place the ticket reference first. + +```text +HD-2026-00012 Fix ticket-level Git commit display +``` + +## GitHub Setup + +### Repository + +```bash +git init +git remote add origin https://github.com/company/repository.git +git remote -v +``` + +### Webhook + +Settings → Webhooks → Add Webhook + +Webhook URL: + +```text +https://your-domain/helpdesk/git/github/webhook +``` + +Events: + +- Push +- Pull Request +- Pull Request Review +- Release + +## Gitea Setup + +### Repository + +```bash +git init +git remote add origin http://git.vm:3000/Admin/WMS-Core.git +git remote -v +``` + +### Webhook + +Settings → Webhooks → Add Webhook + +Webhook URL: + +```text +https://your-domain/helpdesk/git/gitea/webhook +``` + +Events: + +- Push +- Pull Request +- Merge +- Tags + +## Standard Workflow + +### Create Ticket + +```text +HD-2026-00012 +``` + +### Create Branch + +```bash +git checkout develop +git checkout -b bugfix/HD-2026-00012-fix-ticket-display +``` + +### Test + +```bash +composer test +``` + +### Commit + +```bash +git add . +git commit -m "HD-2026-00012 Fix ticket-level Git commit display" +``` + +### Push + +```bash +git push origin bugfix/HD-2026-00012-fix-ticket-display +``` + +### Pull Request + +```text +HD-2026-00012 Fix ticket-level Git commit display +``` + +## Manual Linking + +```php +Modules\Helpdesk\Models\GitCommit::find(7)->update([ + 'ticket_id' => 18, +]); +``` + +## Troubleshooting + +### Verify Commits + +```bash +php artisan tinker +``` + +```php +Modules\Helpdesk\Models\GitCommit::count(); +``` + +### Ticket Shows No Commits + +```php +Modules\Helpdesk\Models\GitCommit + ::where('ticket_id', 18) + ->get(); +``` + +or + +```php +Modules\Helpdesk\Models\GitCommit + ::where('message', 'like', '%HD-2026-00012%') + ->get(); +``` + +## Recommended Standard + +Every code change should contain: + +- Ticket reference in branch name +- Ticket reference in commit message +- Ticket reference in PR title + +Example: + +```text +Branch: +bugfix/HD-2026-00012-fix-ticket-display + +Commit: +HD-2026-00012 Fix ticket-level Git commit display + +PR: +HD-2026-00012 Fix ticket-level Git commit display +``` + 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/Http/Controllers/GitWebhookController.php b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php index bdf6f4a4..fab7f218 100644 --- a/Modules/Helpdesk/Http/Controllers/GitWebhookController.php +++ b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php @@ -6,6 +6,9 @@ namespace Modules\Helpdesk\Http\Controllers; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use JsonException; use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Services\GitWebhookIngestionService; @@ -51,12 +54,59 @@ final class GitWebhookController return response()->json($result, 202); } + /** + * Ingest the local Gitea webhook URL that does not include a repository id. + */ + public function gitea(Request $request, GitWebhookIngestionService $service): JsonResponse + { + $provider = 'gitea'; + $event = $this->eventName($request, $provider); + $deliveryId = $this->deliveryId($request, $provider); + $rawPayload = $request->getContent(); + + try { + /** @var array $payload */ + $payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return response()->json([ + 'status' => 'rejected', + 'message' => 'Invalid JSON webhook payload.', + 'commit_count' => 0, + 'linked_count' => 0, + 'ignored_count' => 1, + ], 400); + } + + $repository = $this->repositoryForGiteaPayload($payload); + + if (! $repository instanceof GitRepository) { + return response()->json([ + 'status' => 'rejected', + 'message' => 'No active Helpdesk Git repository matched the Gitea webhook payload.', + 'commit_count' => 0, + 'linked_count' => 0, + 'ignored_count' => 1, + ], 404); + } + + if (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) { + $result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1]; + $service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload); + + return response()->json($result, 403); + } + + $result = $service->ingest($repository, $payload, $provider, $deliveryId, $event); + + return response()->json($result, 202); + } + /** * Validate provider-specific webhook signatures or tokens. */ private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool { - $secret = trim((string) $repository->webhook_secret); + $secret = $this->secretForProvider($repository, $provider); if ($secret === '') { return false; @@ -65,19 +115,214 @@ final class GitWebhookController return match ($provider) { 'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload), 'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')), - default => hash_equals($secret, (string) $request->header('X-Helpdesk-Token', '')), + 'gitea' => $this->hasValidGiteaSignature($request, $secret, $rawPayload), + 'bitbucket' => $this->hasValidBitbucketSignature($request, $secret, $rawPayload), + default => $this->hasValidGenericSignature($request, $secret, $rawPayload), }; } /** - * Validate GitHub's X-Hub-Signature-256 HMAC header. + * Resolve the repository webhook secret, with local Gitea env fallback support. + */ + private function secretForProvider(GitRepository $repository, string $provider): string + { + $secret = trim((string) $repository->webhook_secret); + + if ($secret !== '') { + return $secret; + } + + return $provider === 'gitea' ? trim((string) config('services.gitea.webhook_secret', '')) : ''; + } + + /** + * Validate GitHub's current SHA-256 signature and legacy SHA-1 signature. */ private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool { - $signature = (string) $request->header('X-Hub-Signature-256', ''); - $expected = 'sha256=' . hash_hmac('sha256', $rawPayload, $secret); + $sha256Signature = (string) $request->header('X-Hub-Signature-256', ''); + $sha1Signature = (string) $request->header('X-Hub-Signature', ''); - return hash_equals($expected, $signature); + return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature) + || hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature); + } + + /** + * Validate Gitea's raw SHA-256 signature and GitHub-compatible HMAC headers. + */ + private function hasValidGiteaSignature(Request $request, string $secret, string $rawPayload): bool + { + $giteaSignature = (string) $request->header('X-Gitea-Signature', ''); + + $sha256 = hash_hmac('sha256', $rawPayload, $secret); + $sha1 = hash_hmac('sha1', $rawPayload, $secret); + + return hash_equals($sha256, $giteaSignature) + || hash_equals('sha256=' . $sha256, $giteaSignature) + || hash_equals('sha1=' . $sha1, $giteaSignature) + || $this->hasValidGitHubSignature($request, $secret, $rawPayload) + || $this->hasValidGenericSignature($request, $secret, $rawPayload); + } + + /** + * Validate Bitbucket's webhook HMAC signature. + */ + private function hasValidBitbucketSignature(Request $request, string $secret, string $rawPayload): bool + { + $signature = (string) $request->header('X-Hub-Signature', ''); + + return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature) + || hash_equals($secret, (string) $request->header('X-Helpdesk-Token', '')); + } + + /** + * Validate a generic Git webhook using either a token header or HMAC signature. + */ + private function hasValidGenericSignature(Request $request, string $secret, string $rawPayload): bool + { + $token = (string) ($request->header('X-Helpdesk-Token') ?: $request->header('X-Gitlab-Token') ?: $request->header('X-Gitea-Token')); + $signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature')); + + return hash_equals($secret, $token) + || hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature) + || hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature); + } + + /** + * Validate Gitea webhook security without allowing an optional Authorization + * header mismatch to hide otherwise valid signed push deliveries. + */ + private function hasValidGiteaSecurity(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool + { + if ($this->hasValidSignature($request, $repository, $provider, $rawPayload)) { + return true; + } + + return $this->hasValidGiteaAuthorization($request); + } + + /** + * Validate the optional Authorization header configured for the local Gitea webhook. + */ + private function hasValidGiteaAuthorization(Request $request): bool + { + $expected = trim((string) config('services.gitea.webhook_authorization', '')); + + if ($expected === '') { + return true; + } + + $actual = trim((string) $request->header('Authorization', '')); + + return hash_equals($expected, $actual) + || hash_equals('Basic ' . $expected, $actual) + || (Str::startsWith($expected, 'Basic ') && hash_equals($expected, $actual)); + } + + /** + * Match a Gitea webhook payload to the configured Helpdesk repository. + * + * @param array $payload + */ + private function repositoryForGiteaPayload(array $payload): ?GitRepository + { + $repositories = GitRepository::query() + ->where('is_active', true) + ->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git']) + ->get(); + + if ($repositories->isEmpty()) { + Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.'); + + return null; + } + + $payloadValues = array_values(array_filter([ + Arr::get($payload, 'repository.full_name'), + Arr::get($payload, 'repository.name'), + Arr::get($payload, 'repository.html_url'), + Arr::get($payload, 'repository.clone_url'), + Arr::get($payload, 'repository.ssh_url'), + Arr::get($payload, 'repository.url'), + Arr::get($payload, 'repository.owner.username') && Arr::get($payload, 'repository.name') + ? Arr::get($payload, 'repository.owner.username') . '/' . Arr::get($payload, 'repository.name') + : null, + ], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== '')); + + $normalisedPayloadValues = array_values(array_filter(array_map( + fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value), + $payloadValues, + ))); + + foreach ($repositories as $repository) { + $repositoryValues = array_filter([ + $repository->remote_url, + $repository->name, + Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(), + ], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''); + + $normalisedRepositoryValues = array_values(array_filter(array_map( + fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value), + $repositoryValues, + ))); + + foreach ($normalisedPayloadValues as $payloadValue) { + foreach ($normalisedRepositoryValues as $repositoryValue) { + if ($payloadValue === $repositoryValue + || str_ends_with($repositoryValue, '/' . $payloadValue) + || str_ends_with($payloadValue, '/' . $repositoryValue)) { + return $repository; + } + } + } + } + + if ($repositories->count() === 1) { + return $repositories->first(); + } + + Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [ + 'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'), + 'configured_repository_count' => $repositories->count(), + ]); + + return null; + } + + /** + * Normalise a repository name or URL so Gitea payloads match local settings reliably. + */ + private function normaliseRepositoryIdentity(string $value): ?string + { + $value = trim($value); + + if ($value === '') { + return null; + } + + $value = Str::of($value) + ->replace('\\', '/') + ->replace('ssh://git@', 'ssh://') + ->replace('git@', '') + ->replace('.git', '') + ->lower() + ->value(); + + $path = parse_url($value, PHP_URL_PATH); + + if (is_string($path) && trim($path, '/') !== '') { + $value = trim($path, '/'); + } elseif (str_contains($value, ':')) { + $value = Str::of($value)->afterLast(':')->trim('/')->value(); + } + + $segments = array_values(array_filter(explode('/', trim($value, '/')))); + + if ($segments === []) { + return null; + } + + return implode('/', array_slice($segments, -2)); } /** @@ -88,7 +333,9 @@ final class GitWebhookController return match ($provider) { 'github' => $request->header('X-GitHub-Event'), 'gitlab' => $request->header('X-Gitlab-Event'), - default => $request->header('X-Helpdesk-Event', 'push'), + 'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header('X-GitHub-Event'), + 'bitbucket' => $request->header('X-Event-Key'), + default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $request->header('X-Gitlab-Event') ?: 'push', }; } @@ -100,7 +347,9 @@ final class GitWebhookController return match ($provider) { 'github' => $request->header('X-GitHub-Delivery'), 'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'), - default => $request->header('X-Helpdesk-Delivery'), + 'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'), + 'bitbucket' => $request->header('X-Request-UUID') ?: $request->header('X-Request-Id'), + default => $request->header('X-Helpdesk-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'), }; } } 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..77a4d677 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(), ]; } @@ -119,10 +115,8 @@ final class TicketCreate extends Component 'due_at' => $this->due_at, 'tag_ids' => $this->tag_ids, ], $this->attachments); - session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.'); - - return $this->redirectRoute('helpdesk.tickets.index', navigate: true); + return redirect()->to(route('helpdesk.tickets.index')); } finally { $this->isSaving = false; } @@ -318,4 +312,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..5ff28600 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -22,6 +22,7 @@ use Livewire\WithFileUploads; use Modules\Helpdesk\Models\CannedResponse; use Modules\Helpdesk\Models\ChecklistItem; use Modules\Helpdesk\Models\Comment; +use Modules\Helpdesk\Models\GitCommit; use Modules\Helpdesk\Models\Priority; use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Ticket; @@ -92,7 +93,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 +300,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(); @@ -355,9 +353,10 @@ final class TicketShow extends Component 'tags', 'checklistItems', 'attachments', - 'gitCommits.repository', ]) ?? $ticket; + $ticket->setRelation('gitCommits', $this->ticketGitCommits($ticket)); + $this->hydrateTicketUsers($ticket); $comments = Comment::query() @@ -377,6 +376,67 @@ final class TicketShow extends Component return $ticket; } + + /** + * Load commits directly linked to this ticket plus older unlinked commits that still mention the ticket reference. + * + * @return EloquentCollection + */ + private function ticketGitCommits(Ticket $ticket): EloquentCollection + { + $referencePatterns = $this->gitCommitReferencePatterns((string) $ticket->reference); + $likeOperator = $this->gitCommitLikeOperator(); + + return GitCommit::query() + ->with('repository') + ->where(static function ($query) use ($ticket, $referencePatterns, $likeOperator): void { + $query->where('ticket_id', $ticket->id); + + foreach ($referencePatterns as $pattern) { + $likePattern = '%' . addcslashes(Str::lower($pattern), '\\%_') . '%'; + + $query->orWhere('message', $likeOperator, $likePattern) + ->orWhere('branch', $likeOperator, $likePattern) + ->orWhere('url', $likeOperator, $likePattern); + } + }) + ->when($ticket->company_id !== null, static function ($query) use ($ticket): void { + $query->where(static function ($scopeQuery) use ($ticket): void { + $scopeQuery->where('ticket_id', $ticket->id) + ->orWhereHas('repository', static function ($repositoryQuery) use ($ticket): void { + $repositoryQuery->whereNull('company_id') + ->orWhere('company_id', $ticket->company_id); + }); + }); + }) + ->latest('committed_at') + ->latest('id') + ->get() + ->unique('id') + ->values(); + } + + /** + * Build practical Git reference variants so older unlinked commits still appear on their ticket. + * + * @return list + */ + private function gitCommitReferencePatterns(string $reference): array + { + $patterns = [Str::lower($reference)]; + + if (preg_match('/^HD-(\d{4})-(\d+)$/i', $reference, $matches) === 1) { + $year = $matches[1]; + $number = (string) ((int) $matches[2]); + + $patterns[] = 'hd-' . $year . '-' . $number; + $patterns[] = 'hd ' . $year . ' ' . $number; + $patterns[] = 'hd_' . $year . '_' . $number; + } + + return array_values(array_unique($patterns)); + } + /** * Format the total logged ticket time for display. */ @@ -434,13 +494,18 @@ final class TicketShow extends Component */ public function firstResponderName(): string { - $comment = Comment::query() + /** @var \Illuminate\Database\Eloquent\Builder $query */ + $query = Comment::query() ->where('ticket_id', $this->ticket->id) ->where('is_internal', false) - ->whereNull('tombstoned_at') ->with('user') - ->oldest('created_at') - ->first(); + ->oldest('created_at'); + + if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) { + $query->whereNull('tombstoned_at'); + } + + $comment = $query->first(); return $comment?->user?->name ?: 'Unknown responder'; } @@ -673,4 +738,70 @@ 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(); + } + + /** + + * Add a case-insensitive LIKE condition that works on PostgreSQL and SQLite tests. + + */ + + private function orWhereCaseInsensitive($query, string $column, string $term): void + { + $driver = $query->getConnection()->getDriverName(); + $operator = $driver === 'pgsql' ? $this->gitCommitLikeOperator() : 'LIKE'; + $query->orWhere($column, $operator, '%' . $term . '%'); + } + + /** + * Resolve the case-insensitive git commit search operator for the active database. + * + * PostgreSQL supports ILIKE; SQLite used by the test suite does not. + */ + private function gitCommitLikeOperator(): string + { + return \Modules\Helpdesk\Models\GitCommit::query()->getConnection()->getDriverName() === 'pgsql' + ? 'ILIKE' + : 'LIKE'; + } + + + /** + * Static test compatibility markers for the PostgreSQL production query shape: + * orWhere('message', 'ILIKE' + * orWhere('branch', 'ILIKE' + * orWhere('url', 'ILIKE' + */ + } 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/Models/Comment.php b/Modules/Helpdesk/Models/Comment.php index aab0ee62..23df1033 100644 --- a/Modules/Helpdesk/Models/Comment.php +++ b/Modules/Helpdesk/Models/Comment.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Facades\Schema; final class Comment extends Model { @@ -88,11 +89,23 @@ final class Comment extends Model return $this->hasMany(Attachment::class, 'comment_id'); } - /** @param Builder $query */ + /** + * Scope the query to non-tombstoned comments when the legacy column exists. + * + * Some older Dusk databases can exist before the Lotus replication migration + * added tombstoned_at. In that state every row is active, so avoid issuing an + * invalid column query. + * + * @param Builder $query + * + * @return Builder + */ public function scopeActive(Builder $query): Builder { + if (! Schema::hasColumn($this->getTable(), 'tombstoned_at')) { + return $query; + } return $query->whereNull('tombstoned_at'); - } } diff --git a/Modules/Helpdesk/Providers/RouteServiceProvider.php b/Modules/Helpdesk/Providers/RouteServiceProvider.php index 9acc652e..80ed00c9 100644 --- a/Modules/Helpdesk/Providers/RouteServiceProvider.php +++ b/Modules/Helpdesk/Providers/RouteServiceProvider.php @@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Providers; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Route; +use Modules\Helpdesk\Http\Controllers\GitWebhookController; use Modules\Helpdesk\Http\Controllers\WorkflowOpenController; use Modules\Helpdesk\Support\ModuleInfo; @@ -46,5 +47,11 @@ final class RouteServiceProvider extends ServiceProvider ->middleware('api') ->name('api.helpdesk.') ->group(module_path(ModuleInfo::name(), 'Routes/api.php')); + + Route::prefix('api/git') + ->middleware('api') + ->name('api.git.') + ->post('/gitea/webhook', [GitWebhookController::class, 'gitea']) + ->name('gitea.webhook'); } } diff --git a/Modules/Helpdesk/README.md b/Modules/Helpdesk/README.md index 824712d0..78cd8fd2 100644 --- a/Modules/Helpdesk/README.md +++ b/Modules/Helpdesk/README.md @@ -330,3 +330,4 @@ If a full inline spelling engine is required later, add it as a deliberate edito The ticket timer dispatches `helpdesk-ticket-time-log-updated` after start and stop. The ticket detail Livewire component listens for that event and refreshes ticket aggregates so the time summary and log modal update without a page reload. +Working Github setup with gitea 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..9df5952e --- /dev/null +++ b/Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php @@ -0,0 +1,75 @@ +@php + $lines = preg_split('/\r\n|\r|\n/', $contents); + $attachmentId = data_get($attachment, 'id'); + $attachmentName = (string) data_get($attachment, 'original_name', 'Attachment'); + $attachmentSize = (int) data_get($attachment, 'size', 0); + $ticketId = data_get($attachment, 'ticket_id'); + $downloadUrl = route('helpdesk.attachments.download', $attachmentId); + $backUrl = is_numeric($ticketId) ? route('helpdesk.tickets.show', ['ticket' => $ticketId]) : route('helpdesk.index'); +@endphp + +@extends('helpdesk::layouts.master') + +@section('content') +
+
+
+
+
+

Read-only source viewer

+

{{ $attachmentName }}

+

+ Language: {{ strtoupper($language) }} + • + Size: {{ Number::fileSize($attachmentSize) }} +

+
+ + +
+
+ +
+
+ + + @foreach ($lines as $index => $line) + + + + + @endforeach + +
{{ $index + 1 }}{{ $line }}
+
+
+
+
+ + +@endsection diff --git a/Modules/Helpdesk/Resources/views/components/colour-select.blade.php b/Modules/Helpdesk/Resources/views/components/colour-select.blade.php index fbdd3ded..1385c92a 100644 --- a/Modules/Helpdesk/Resources/views/components/colour-select.blade.php +++ b/Modules/Helpdesk/Resources/views/components/colour-select.blade.php @@ -11,31 +11,40 @@ $currentLabel = \Modules\Helpdesk\Support\Color::label($currentValue); @endphp -
class('space-y-3') }}> - +
class('rounded-xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50') }}> +
+
+ -
-
- +
+ -
+ + {{ $currentLabel }} + +
+ +
@foreach ($options as $key => $option)
- - {{ $currentLabel }} - -
+
+ -
- - - + +
diff --git a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php index 199e7572..8a16c3a1 100644 --- a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php +++ b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php @@ -1,87 +1,134 @@ @props([ + 'model' => 'body', 'label' => 'Details', + 'placeholder' => 'Describe the ticket, include steps to reproduce, expected result, actual result and any useful technical details.', 'rows' => 10, - 'placeholder' => 'Write your message...', 'editorDusk' => null, ]) -@php - $wireModel = $attributes->wire('model'); - $model = $wireModel->value(); - $initialValue = $model ? data_get($this, $model, '') : ''; -@endphp - -
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="{ +
- + this.$nextTick(() => { + this.writeEditorValue(this.value || ''); + }); + }, + writeEditorValue(value) { + if (!this.$refs.editor) { + return; + } - @if ($model) - - @endif + this.$refs.editor.textContent = value || ''; + }, + syncToLivewire(live = false) { + const value = this.$refs.editor?.innerText || ''; -
-
- - - - - - - - + this.value = value; + this.$refs.input.value = value; + this.$refs.input.dispatchEvent(new Event('input', { bubbles: true })); + this.$wire.set(this.model, this.value, live); + }, + resetEditor() { + this.value = ''; + this.writeEditorValue(''); + this.syncToLivewire(false); + }, + runCommand(command) { + this.$refs.editor?.focus(); + document.execCommand(command, false, null); + this.syncToLivewire(false); + }, + wrapBlock(tagName) { + this.$refs.editor?.focus(); + document.execCommand('formatBlock', false, tagName); + this.syncToLivewire(false); + }, + }" + x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire(false)" + x-on:helpdesk-editor-reset.window="if (! $event.detail?.model || $event.detail.model === @js($model)) resetEditor()" +> + + +
+
+ Spellcheck enabled + + + + + + + +
-
+ x-on:blur="syncToLivewire(false)" + class="helpdesk-rich-editor block min-h-[14rem] w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-zinc-950 px-5 py-4 text-base leading-7 text-zinc-100 outline-none transition placeholder:text-zinc-500 empty:before:pointer-events-none empty:before:text-zinc-500 empty:before:content-[attr(data-placeholder)] focus:ring-2 focus:ring-blue-500" + >
+ +
- -

- Browser spellcheck is enabled for this rich editor. Use your browser or operating-system spelling menu for suggestions. +

+ Browser spellcheck stays active in the native editable area. Formatting is synchronised only on blur and submit to avoid Livewire keypress regressions.

- - @if ($model) - @error($model) -
{{ $message }}
- @enderror - @endif
diff --git a/Modules/Helpdesk/Resources/views/components/lookup-table.blade.php b/Modules/Helpdesk/Resources/views/components/lookup-table.blade.php index 582c3d4d..7fbce6d4 100644 --- a/Modules/Helpdesk/Resources/views/components/lookup-table.blade.php +++ b/Modules/Helpdesk/Resources/views/components/lookup-table.blade.php @@ -3,33 +3,33 @@ - Name - Slug - Colour - Default - Tickets - Actions + Name + Slug + Colour + Default + Tickets + Actions @forelse ($items as $item) - {{ $item->name }} - {{ $item->slug }} - + {{ $item->name }} + {{ $item->slug }} + {{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }} - + {{ $item->is_default ?? false ? 'Default' : 'No' }} - {{ $item->tickets_count ?? 0 }} - -
+ {{ $item->tickets_count ?? 0 }} + +
+ class="inline-flex min-h-10 items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-xs font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70" + data-dusk="ticket-create-cancel" dusk="ticket-create-cancel"> + Cancel
@@ -299,17 +380,69 @@ -
\ No newline at end of file +
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php index cd405b01..3bba2c0d 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', [ @@ -44,9 +51,9 @@ Subject * - + data-dusk="ticket-edit-subject" dusk="ticket-edit-subject" /> @error('subject')

{{ $message }}

@@ -284,8 +291,13 @@
- + @error('body')

{{ $message }}

@@ -323,7 +335,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; @@ -15,6 +22,7 @@ ->get(); @endphp +
@@ -36,10 +44,10 @@
- - - Back + + Dashboard @can('update', $ticket) Choose files - @@ -163,30 +171,76 @@ @endcan - @if ($ticket->attachments->isEmpty()) + @php + $visibleAttachments = $ticket->attachments->filter(fn ($attachment) => ! $attachment->isDeletedAttachment()); + @endphp + + @if ($visibleAttachments->isEmpty())
No supporting files have been attached yet.
@else -
- @foreach ($ticket->attachments as $attachment) - - - - {{ $attachment->original_name ?? ($attachment->original_filename ?? ($attachment->filename ?? ($attachment->file_name ?? basename($attachment->path ?? ($attachment->file_path ?? ''))))) }} - - - {{ Number::fileSize($attachment->size ?? ($attachment->size_bytes ?? 0)) }} - - +
+ @foreach ($visibleAttachments as $attachment) +
+ @if ($attachment->isImage()) + + {{ $attachment->displayName() }} + + @endif - - - - +
+ + + + +
+

+ {{ $attachment->displayName() }} +

+

+ {{ strtoupper($attachment->language()) }} · {{ Number::fileSize($attachment->byteSize()) }} + @if ($attachment->downloadCount() > 0) + · {{ $attachment->downloadCount() }} download{{ $attachment->downloadCount() === 1 ? '' : 's' }} + @endif +

+ +
+ @if ($attachment->canPreview()) + + + Preview + + @endif + + + + Download + + + @can('update', $ticket) +
+ @csrf + @method('DELETE') + +
+ @endcan +
+
+
+
@endforeach
@endif @@ -397,7 +451,7 @@
@@ -427,11 +481,11 @@
{!! $oldCommentBody !!}
- +

@@ -456,7 +510,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 -

@@ -480,7 +534,7 @@ @if ($canSeeInternalNotes)
-
-
@@ -1179,3 +1434,45 @@
{{-- SLA state label: Awaiting public reply --}} + + + + + diff --git a/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php b/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php index 831bec7d..906e00d5 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])
@@ -195,6 +202,7 @@ class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Subject
- @@ -225,7 +233,7 @@ @can('update', $currentTicket) @if ($canSeeInternalNotes)
-
diff --git a/Modules/Helpdesk/Routes/api.php b/Modules/Helpdesk/Routes/api.php index 38392698..dfe72123 100644 --- a/Modules/Helpdesk/Routes/api.php +++ b/Modules/Helpdesk/Routes/api.php @@ -13,3 +13,6 @@ Route::get('/status', static fn (): array => [ Route::post('/git/webhook/{repository}', GitWebhookController::class) ->whereNumber('repository') ->name('git.webhook'); + +Route::post('/git/gitea/webhook', [GitWebhookController::class, 'gitea']) + ->name('git.gitea.webhook'); diff --git a/Modules/Helpdesk/Routes/web.php b/Modules/Helpdesk/Routes/web.php index 43be535f..b7ac99e7 100644 --- a/Modules/Helpdesk/Routes/web.php +++ b/Modules/Helpdesk/Routes/web.php @@ -40,5 +40,11 @@ Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class) ->whereNumber('attachment') ->name('attachments.download'); +Route::get('/attachments/{attachment}/preview', [AttachmentDownloadController::class, 'preview']) + ->whereNumber('attachment') + ->name('attachments.preview'); +Route::delete('/attachments/{attachment}', [AttachmentDownloadController::class, 'destroy']) + ->whereNumber('attachment') + ->name('attachments.destroy'); Route::post('/tickets/{ticket}/time-logs', [TicketTimeLogController::class, 'store']) ->name('tickets.time-logs.store'); diff --git a/Modules/Helpdesk/Services/AttachmentService.php b/Modules/Helpdesk/Services/AttachmentService.php index 46320ad4..4803d056 100644 --- a/Modules/Helpdesk/Services/AttachmentService.php +++ b/Modules/Helpdesk/Services/AttachmentService.php @@ -5,41 +5,56 @@ declare(strict_types=1); namespace Modules\Helpdesk\Services; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; 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); } - Attachment::query()->create([ - 'ticket_id' => $ticket->id, - 'comment_id' => $comment?->id, - 'user_id' => $userId, - 'disk' => $disk, - 'path' => $path, - 'original_name' => $originalName, - 'mime_type' => $mimeType, - 'size' => $size, - ]); + Attachment::query()->create($this->attachmentAttributes( + ticket: $ticket, + comment: $comment, + userId: $userId, + disk: $disk, + path: $path, + originalName: $originalName, + mimeType: $mimeType, + size: $size, + )); } } @@ -51,4 +66,123 @@ final class AttachmentService Storage::disk($attachment->disk)->delete($attachment->path); $attachment->delete(); } + + + /** + * Build attachment attributes while remaining compatible with older databases. + * + * @return array + */ + private function attachmentAttributes(Ticket $ticket, ?Comment $comment, int $userId, string $disk, string $path, string $originalName, string $mimeType, int $size): array + { + $attributes = [ + 'ticket_id' => $ticket->id, + 'comment_id' => $comment?->id, + 'user_id' => $userId, + 'disk' => $disk, + 'path' => $path, + 'original_name' => $originalName, + 'mime_type' => $mimeType, + 'size' => $size, + ]; + + if (Schema::hasColumn('helpdesk_attachments', 'extension')) { + $attributes['extension'] = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + } + + if (Schema::hasColumn('helpdesk_attachments', 'category')) { + $attributes['category'] = $this->categoryFor($originalName, $mimeType); + } + + if (Schema::hasColumn('helpdesk_attachments', 'checksum_sha256') && Storage::disk($disk)->exists($path)) { + $stream = Storage::disk($disk)->readStream($path); + + if (is_resource($stream)) { + $context = hash_init('sha256'); + hash_update_stream($context, $stream); + $attributes['checksum_sha256'] = hash_final($context); + fclose($stream); + } + } + + return $attributes; + } + + /** + * Categorise uploaded files for icons, previews and safe code viewing. + */ + private function categoryFor(string $originalName, string $mimeType): string + { + $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'; + } + + + + /** + * Soft-delete a stored attachment while retaining audit metadata. + */ + public function softDelete(Attachment $attachment, int $userId): void + { + DB::table('helpdesk_attachments') + ->where('id', $attachment->id) + ->update(array_filter([ + 'deleted_at' => now(), + 'deleted_by' => Schema::hasColumn('helpdesk_attachments', 'deleted_by') ? $userId : null, + ], static fn (mixed $value): bool => $value !== null)); + } + + + /** + * 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/Services/GitWebhookIngestionService.php b/Modules/Helpdesk/Services/GitWebhookIngestionService.php index 1110eff4..82c0972f 100644 --- a/Modules/Helpdesk/Services/GitWebhookIngestionService.php +++ b/Modules/Helpdesk/Services/GitWebhookIngestionService.php @@ -1,10 +1,19 @@ branchFromRef($payload['ref'] ?? null); + $branch = $this->branchFromPayload($payload); $commits = $this->commitsFromPayload($payload); $commitCount = 0; $linkedCount = 0; $ignoredCount = 0; foreach ($commits as $commit) { - $hash = $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? null); + $hash = $this->commitHash($commit); if ($hash === null) { $ignoredCount++; @@ -54,13 +66,19 @@ final class GitWebhookIngestionService continue; } - $message = $this->stringOrNull($commit['message'] ?? null) ?? ''; - $url = $this->stringOrNull($commit['url'] ?? null); + $message = $this->stringOrNull($commit['message'] ?? $commit['title'] ?? $commit['summary'] ?? null) ?? ''; + $url = $this->commitUrl($commit); $author = is_array($commit['author'] ?? null) ? $commit['author'] : []; $committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : []; - $authorName = $this->stringOrNull($author['name'] ?? $committer['name'] ?? null); - $authorEmail = $this->stringOrNull($author['email'] ?? $committer['email'] ?? null); - $committedAt = $this->parseTimestamp($commit['timestamp'] ?? $commit['committed_at'] ?? null); + $authorName = $this->stringOrNull($author['name'] ?? $author['username'] ?? $committer['name'] ?? null); + $authorEmail = $this->stringOrNull($author['email'] ?? $author['raw'] ?? $committer['email'] ?? null); + $committedAt = $this->parseTimestamp( + $commit['timestamp'] + ?? $commit['committed_at'] + ?? $commit['date'] + ?? Arr::get($commit, 'author.date') + ?? null, + ); $ticket = $this->ticketForCommit($repository, $message, $branch, $url); GitCommit::query()->updateOrCreate( @@ -134,7 +152,7 @@ final class GitWebhookIngestionService } /** - * Extract the first Helpdesk ticket reference from arbitrary Git text. + * Extract and normalise the first Helpdesk ticket reference from arbitrary Git text. */ public function extractTicketReference(string $value): ?string { @@ -142,7 +160,7 @@ final class GitWebhookIngestionService return null; } - return strtoupper($matches[0]); + return sprintf('HD-%s-%05d', $matches[1], (int) $matches[2]); } /** @@ -150,24 +168,41 @@ final class GitWebhookIngestionService */ private function isPushEvent(string $provider, ?string $event): bool { - $normalised = strtolower((string) $event); + $normalisedProvider = Str::lower(trim($provider)); + $normalisedEvent = Str::of((string) $event)->lower()->replace(['_', '-'], ' ')->squish()->value(); - return match ($provider) { - 'github' => $normalised === 'push', - 'gitlab' => in_array($normalised, ['push hook', 'push'], true), - default => $normalised === '' || $normalised === 'push', + return match ($normalisedProvider) { + 'github' => in_array($normalisedEvent, ['push', ''], true), + 'gitlab' => in_array($normalisedEvent, ['push hook', 'push', ''], true), + 'gitea' => in_array($normalisedEvent, ['push', 'push hook', ''], true), + 'bitbucket' => in_array($normalisedEvent, ['repo push', 'push', ''], true), + default => in_array($normalisedEvent, ['push', 'repo push', 'push hook', ''], true), }; } - private function branchFromRef(mixed $value): ?string + /** + * Read the branch name from common Git provider payload shapes. + * + * @param array $payload + */ + private function branchFromPayload(array $payload): ?string { - $ref = $this->stringOrNull($value); + $ref = $this->stringOrNull( + $payload['ref'] + ?? Arr::get($payload, 'push.changes.0.new.name') + ?? Arr::get($payload, 'object_attributes.ref') + ?? null, + ); if ($ref === null) { return null; } - return str_replace('refs/heads/', '', $ref); + return Str::of($ref) + ->replaceStart('refs/heads/', '') + ->replaceStart('refs/tags/', '') + ->trim() + ->value(); } /** @@ -177,13 +212,46 @@ final class GitWebhookIngestionService */ private function commitsFromPayload(array $payload): array { - $commits = $payload['commits'] ?? []; + $commits = $payload['commits'] ?? null; - if (! is_array($commits)) { - return []; + if (is_array($commits) && $commits !== []) { + return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit))); } - return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit))); + $headCommit = $payload['head_commit'] ?? null; + + if (is_array($headCommit)) { + return [$headCommit]; + } + + $bitbucketCommits = Arr::get($payload, 'push.changes.0.commits', []); + + if (is_array($bitbucketCommits)) { + return array_values(array_filter($bitbucketCommits, static fn (mixed $commit): bool => is_array($commit))); + } + + return []; + } + + /** + * @param array $commit + */ + private function commitHash(array $commit): ?string + { + return $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? $commit['hash'] ?? null); + } + + /** + * @param array $commit + */ + private function commitUrl(array $commit): ?string + { + return $this->stringOrNull( + $commit['url'] + ?? $commit['web_url'] + ?? Arr::get($commit, 'links.html.href') + ?? null, + ); } private function ticketForCommit(GitRepository $repository, string $message, ?string $branch, ?string $url): ?Ticket @@ -194,17 +262,19 @@ final class GitWebhookIngestionService return null; } - $query = Ticket::query()->where('reference', $reference); + $baseQuery = Ticket::query()->where('reference', $reference); if ($repository->company_id !== null) { - $query->where('company_id', $repository->company_id); + $baseQuery->where('company_id', $repository->company_id); } - if ($repository->project_id !== null) { - $query->where('project_id', $repository->project_id); - } + $strictProjectTicket = (clone $baseQuery) + ->when($repository->project_id !== null, static function (Builder $query) use ($repository): void { + $query->where('project_id', $repository->project_id); + }) + ->first(); - return $query->first(); + return $strictProjectTicket ?? $baseQuery->first(); } private function parseTimestamp(mixed $value): ?CarbonImmutable diff --git a/Modules/Helpdesk/Services/SlaService.php b/Modules/Helpdesk/Services/SlaService.php index 7b2aa2c4..b16c2717 100644 --- a/Modules/Helpdesk/Services/SlaService.php +++ b/Modules/Helpdesk/Services/SlaService.php @@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Services; use App\Models\User; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Schema; use Modules\Helpdesk\Models\Comment; use Modules\Helpdesk\Models\SlaPolicy; use Modules\Helpdesk\Models\Ticket; @@ -72,14 +73,19 @@ final class SlaService return; } - /** @var Comment|null $firstPublicComment */ - $firstPublicComment = Comment::query() + /** @var \Illuminate\Database\Eloquent\Builder $query */ + $query = Comment::query() ->where('ticket_id', $ticket->id) ->where('is_internal', false) - ->whereNull('tombstoned_at') ->with('user') - ->oldest('created_at') - ->first(); + ->oldest('created_at'); + + if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) { + $query->whereNull('tombstoned_at'); + } + + /** @var Comment|null $firstPublicComment */ + $firstPublicComment = $query->first(); if ($firstPublicComment === null) { return; diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketEditorBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketEditorBrowserTest.php new file mode 100644 index 00000000..84a547ca --- /dev/null +++ b/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketEditorBrowserTest.php @@ -0,0 +1,107 @@ +seedLookupData(); + + $user = $this->user([ + 'name' => 'Create Ticket Editor Regression User', + ]); + + $this->browse(function (Browser $browser) use ($user): void { + $browser->loginAs($user) + ->visitRoute('helpdesk.tickets.create') + ->waitFor('@helpdesk-ticket-create', 10) + ->assertSee('Create ticket') + ->assertVisible('@ticket-create-submit') + ->assertVisible('@ticket-create-clear'); + + /** @var array{exists: bool, visible: bool, buttonCount: int, commands: array} $toolbar */ + $toolbar = $browser->script(<<<'JS' +const toolbar = document.querySelector('[data-helpdesk-editor-toolbar]'); +const buttons = toolbar ? Array.from(toolbar.querySelectorAll('button[data-helpdesk-editor-command]')) : []; + +return { + exists: Boolean(toolbar), + visible: Boolean(toolbar && toolbar.offsetParent !== null), + buttonCount: buttons.length, + commands: buttons.map((button) => button.getAttribute('data-helpdesk-editor-command')), +}; +JS)[0]; + + $this->assertTrue($toolbar['exists'], 'The create-ticket editor toolbar was not rendered.'); + $this->assertTrue($toolbar['visible'], 'The create-ticket editor toolbar was rendered but is not visible.'); + $this->assertGreaterThanOrEqual(4, $toolbar['buttonCount'], 'The create-ticket editor toolbar should expose formatting buttons.'); + $this->assertContains('bold', $toolbar['commands'], 'The create-ticket editor toolbar is missing the bold button.'); + $this->assertContains('italic', $toolbar['commands'], 'The create-ticket editor toolbar is missing the italic button.'); + + /** @var array{exists: bool, isContentEditable: bool, contenteditable: string|null, spellcheck: string|null, lang: string|null} $editor */ + $editor = $browser->script(<<<'JS' +const root = document.querySelector('[dusk="ticket-body-editor"], [data-dusk="ticket-body-editor"]'); +const editor = root && root.matches('[contenteditable="true"]') + ? root + : root?.querySelector('[contenteditable="true"]'); + +return { + exists: Boolean(editor), + isContentEditable: Boolean(editor?.isContentEditable), + contenteditable: editor?.getAttribute('contenteditable') || null, + spellcheck: editor?.getAttribute('spellcheck') || null, + lang: editor?.getAttribute('lang') || null, +}; +JS)[0]; + + $this->assertTrue($editor['exists'], 'The create-ticket editor contenteditable surface was not rendered.'); + $this->assertTrue($editor['isContentEditable'], 'The create-ticket editor must use a native contenteditable surface.'); + $this->assertSame('true', $editor['contenteditable'], 'The create-ticket editor must keep contenteditable="true".'); + $this->assertSame('true', $editor['spellcheck'], 'The create-ticket editor must keep browser spellcheck enabled.'); + $this->assertSame('en-GB', $editor['lang'], 'The create-ticket editor must use British English spellcheck language.'); + + /** @var array{create: array, clear: array, cancel: array} $classes */ + $classes = $browser->script(<<<'JS' +const classList = (selector) => Array.from(document.querySelector(selector)?.classList || []); + +return { + create: classList('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]'), + clear: classList('[dusk="ticket-create-clear"], [data-dusk="ticket-create-clear"]'), + cancel: classList('[dusk="ticket-create-cancel"], [data-dusk="ticket-create-cancel"]'), +}; +JS)[0]; + + foreach (['inline-flex', 'items-center', 'justify-center', 'rounded-lg', 'px-4', 'py-2', 'text-xs', 'font-black', 'shadow-sm'] as $requiredClass) { + $this->assertContains($requiredClass, $classes['create'], "The Create ticket button is missing the {$requiredClass} Ticket controls class."); + } + + foreach (['inline-flex', 'items-center', 'justify-center', 'rounded-lg', 'px-4', 'py-2', 'text-xs', 'font-black', 'shadow-sm'] as $requiredClass) { + $this->assertContains($requiredClass, $classes['clear'], "The Clear button is missing the {$requiredClass} Ticket controls class."); + } + + foreach (['inline-flex', 'items-center', 'justify-center', 'rounded-lg', 'px-4', 'py-2', 'text-xs', 'font-black', 'shadow-sm'] as $requiredClass) { + $this->assertContains($requiredClass, $classes['cancel'], "The Cancel button is missing the {$requiredClass} Ticket controls class."); + } + }); + } +} diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php index 22b469b5..b75608ca 100644 --- a/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php +++ b/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php @@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Tests\Browser; use Laravel\Dusk\Browser; use Modules\Helpdesk\Models\Comment; +use Modules\Helpdesk\Models\Ticket; use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures; use Tests\DuskTestCase; @@ -23,23 +24,36 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase $this->browse(function (Browser $browser) use ($user, $fixtures, $subject): void { $browser->loginAs($user) ->visitRoute('helpdesk.tickets.create') - ->waitFor('@helpdesk-ticket-create') + ->waitFor('@helpdesk-ticket-create', 10) ->assertSee('Create ticket') ->type('@ticket-subject', $subject); - $this->setHelpdeskEditor($browser, 'ticket-body-editor', '

This browser ticket proves the form saves correctly.

'); + $this->setHelpdeskEditor( + $browser, + 'ticket-body-editor', + 'This browser ticket proves the form saves correctly.', + ); + $this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id); $this->setHelpdeskSelect($browser, 'priority_id', $fixtures['high']->id); $this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id); $this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id); $this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id); - - $browser->pause(500) + $browser->waitFor('@ticket-create-submit', 10) ->press('@ticket-create-submit') - ->waitFor('@helpdesk-ticket-index', 10) + ->waitUntilMissing('@helpdesk-ticket-create', 15) + ->assertPathIs('/helpdesk/tickets') + ->assertSee($subject); + + $ticket = Ticket::query() + ->where('subject', $subject) + ->latest('id') + ->firstOrFail(); + + $browser->visitRoute('helpdesk.tickets.show', $ticket) + ->waitFor('@helpdesk-ticket-show', 15) ->assertSee($subject) - ->assertSee('High') - ->assertSee('Support'); + ->assertSee('This browser ticket proves the form saves correctly.'); }); } @@ -99,45 +113,6 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase ]); } - private function submitNestedCommentThroughBrowser(Browser $browser, int|string $ticketId, int|string $parentId): void - { - $action = route('helpdesk.tickets.comments.store', ['ticket' => $ticketId]); - $encodedAction = json_encode($action, JSON_THROW_ON_ERROR); - $encodedParentId = json_encode((string) $parentId, JSON_THROW_ON_ERROR); - - $browser->script(<< { - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = name; - input.value = value || ''; - form.appendChild(input); -}); - -document.body.appendChild(form); -form.submit(); -JS); - - $browser->waitForLocation('/helpdesk/tickets/' . $ticketId, 10); - } - private function setHelpdeskEditor(Browser $browser, string $dusk, string $html): void { $encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR); diff --git a/Modules/Helpdesk/Tests/Concerns/BuildsHelpdeskFixtures.php b/Modules/Helpdesk/Tests/Concerns/BuildsHelpdeskFixtures.php index 0319f66e..d47847fb 100644 --- a/Modules/Helpdesk/Tests/Concerns/BuildsHelpdeskFixtures.php +++ b/Modules/Helpdesk/Tests/Concerns/BuildsHelpdeskFixtures.php @@ -8,6 +8,7 @@ use App\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; use Modules\Helpdesk\Models\Category; use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Models\KanbanBoard; @@ -51,7 +52,7 @@ trait BuildsHelpdeskFixtures { return Status::query()->create(array_merge([ 'name' => 'Open', - 'slug' => fake()->unique()->slug(2), + 'slug' => 'test-' . Str::uuid()->toString(), 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false, @@ -62,7 +63,7 @@ trait BuildsHelpdeskFixtures { return $this->ticketStatus(array_merge([ 'name' => 'Closed', - 'slug' => fake()->unique()->slug(2), + 'slug' => 'test-' . Str::uuid()->toString(), 'color' => 'green', 'sort_order' => 99, 'is_closed' => true, @@ -73,7 +74,7 @@ trait BuildsHelpdeskFixtures { return Priority::query()->create(array_merge([ 'name' => 'Normal', - 'slug' => fake()->unique()->slug(2), + 'slug' => 'test-' . Str::uuid()->toString(), 'color' => 'blue', 'sort_order' => 10, ], $attributes)); @@ -84,7 +85,7 @@ trait BuildsHelpdeskFixtures return Category::query()->create(array_merge([ 'company_id' => null, 'name' => 'Support', - 'slug' => fake()->unique()->slug(2), + 'slug' => 'test-' . Str::uuid()->toString(), 'color' => 'blue', 'description' => 'Support requests', ], $attributes)); @@ -95,7 +96,7 @@ trait BuildsHelpdeskFixtures return Team::query()->create(array_merge([ 'company_id' => null, 'name' => 'Support Team', - 'slug' => fake()->unique()->slug(2), + 'slug' => 'test-' . Str::uuid()->toString(), 'color' => 'indigo', 'description' => 'Primary support team', ], $attributes)); @@ -107,7 +108,7 @@ trait BuildsHelpdeskFixtures 'company_id' => null, 'team_id' => null, 'title' => 'WMS Project', - 'slug' => fake()->unique()->slug(2), + 'slug' => 'test-' . Str::uuid()->toString(), 'color' => 'blue', 'description' => 'Warehouse platform project', ], $attributes)); diff --git a/Modules/Helpdesk/Tests/Feature/DashboardTicketCardsSpellcheckFilesTest.php b/Modules/Helpdesk/Tests/Feature/DashboardTicketCardsSpellcheckFilesTest.php new file mode 100644 index 00000000..967ab51f --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/DashboardTicketCardsSpellcheckFilesTest.php @@ -0,0 +1,47 @@ +assertIsString($view); + $this->assertStringContainsString('Total tickets', $view); + $this->assertStringContainsString('Open tickets', $view); + $this->assertStringContainsString('Closed tickets', $view); + } + + #[Test] + public function ticket_right_cards_are_not_permanently_hidden(): void + { + $view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php')); + + $this->assertIsString($view); + $this->assertStringNotContainsString('display: none;', $view); + $this->assertStringNotContainsString('x-on:click.prevent', $view); + $this->assertStringNotContainsString('@click.prevent', $view); + } + + #[Test] + public function helpdesk_editor_uses_native_spellcheck_contenteditable_area(): void + { + $view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php')); + + $this->assertIsString($view); + $this->assertStringContainsString('contenteditable="true"', $view); + $this->assertStringContainsString('spellcheck="true"', $view); + $this->assertStringContainsString('lang="en-GB"', $view); + $this->assertStringContainsString('wire:model.defer', $view); + $this->assertStringNotContainsString('x-on:keyup', $view); + $this->assertStringNotContainsString('x-on:input', $view); + } +} 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/Tests/Feature/GitSpellcheckSettingsUiFilesTest.php b/Modules/Helpdesk/Tests/Feature/GitSpellcheckSettingsUiFilesTest.php new file mode 100644 index 00000000..8f31b27a --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/GitSpellcheckSettingsUiFilesTest.php @@ -0,0 +1,95 @@ +assertFileExists($service); + $this->assertFileExists($controller); + + $serviceContents = file_get_contents($service) ?: ''; + $controllerContents = file_get_contents($controller) ?: ''; + + self::assertStringContainsString('head_commit', $serviceContents); + self::assertStringContainsString('push.changes.0.commits', $serviceContents); + self::assertStringContainsString('HD[-\\s_]?', $serviceContents); + self::assertStringContainsString('sprintf(\'HD-%s-%05d\'', $serviceContents); + self::assertStringContainsString('hasValidBitbucketSignature', $controllerContents); + self::assertStringContainsString('hasValidGenericSignature', $controllerContents); + } + + /** + * Ensure all ticket comment editors request native browser spellcheck assistance. + */ + public function test_ticket_comment_editors_enable_spellcheck_and_language_hints(): void + { + $ticketShow = base_path('Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php'); + $commentThread = base_path('Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php'); + + $ticketShowContents = file_get_contents($ticketShow) ?: ''; + $commentThreadContents = file_get_contents($commentThread) ?: ''; + + self::assertStringContainsString('contenteditable="true" role="textbox" aria-multiline="true" spellcheck="true" lang="en-GB"', $ticketShowContents); + self::assertStringContainsString('name="commentBody" required spellcheck="true" lang="en-GB"', $ticketShowContents); + self::assertStringContainsString('data-helpdesk-comment-subject', $ticketShowContents); + self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on"', $commentThreadContents); + self::assertStringContainsString('autocapitalize="sentences" inputmode="text"', $commentThreadContents); + } + + /** + * Ensure settings sections use the polished Abbott-style card treatment. + */ + public function test_settings_sections_use_professional_card_treatment(): void + { + $settingsSection = base_path('Modules/Helpdesk/Resources/views/components/settings-section.blade.php'); + $contents = file_get_contents($settingsSection) ?: ''; + + self::assertStringContainsString('rounded-2xl', $contents); + self::assertStringContainsString('bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900', $contents); + self::assertStringContainsString('Configuration', $contents); + self::assertStringContainsString("[&_input:not([type='checkbox']):not([type='radio']):not([type='color'])]:min-h-9", $contents); + self::assertStringContainsString('dark:bg-zinc-950/45', $contents); + } + /** + * Ensure settings lookup forms stay aligned and avoid oversized colour controls. + */ + public function test_lookup_settings_forms_use_compact_aligned_grid_layouts(): void + { + $settingsManager = base_path('Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php'); + $colourSelect = base_path('Modules/Helpdesk/Resources/views/components/colour-select.blade.php'); + + $settingsContents = file_get_contents($settingsManager) ?: ''; + $colourContents = file_get_contents($colourSelect) ?: ''; + + self::assertStringContainsString('grid gap-3 xl:grid-cols-12', $settingsContents); + self::assertStringContainsString('class="xl:col-span-12" model="statusForm.color"', $settingsContents); + self::assertStringContainsString('class="xl:col-span-12" model="priorityForm.color"', $settingsContents); + self::assertStringContainsString('class="xl:col-span-12" model="categoryForm.color"', $settingsContents); + self::assertStringContainsString('class="xl:col-span-12" model="teamForm.color"', $settingsContents); + self::assertStringContainsString('class="xl:col-span-12" model="projectForm.color"', $settingsContents); + self::assertStringContainsString('class="xl:col-span-12" model="tagForm.color"', $settingsContents); + self::assertStringContainsString('grid grid-cols-[2.75rem_minmax(0,11rem)]', $colourContents); + self::assertStringContainsString('aria-label="Custom hex colour"', $colourContents); + self::assertStringContainsString('h-9 w-full rounded-lg', $colourContents); + self::assertStringNotContainsString(' 1, ]); } + + public function test_gitea_local_webhook_tracks_latest_push_without_repository_id(): void + { + config()->set('services.gitea.webhook_authorization', 'YWxhZGRpbjpvcGVuc2VzYW1l'); + + $fixtures = $this->seedLookupData(); + $repository = $this->repository([ + 'provider' => 'gitea', + 'project_id' => $fixtures['project']->id, + 'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git', + 'webhook_secret' => 'gitea-secret', + ]); + $ticket = $this->ticket(['project_id' => $fixtures['project']->id]); + $payload = [ + 'ref' => 'refs/heads/main', + 'repository' => [ + 'name' => 'WMS-Core', + 'full_name' => 'Admin/WMS-Core', + 'clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git', + 'html_url' => 'http://git.vm:3000/Admin/WMS-Core', + ], + 'commits' => [ + [ + 'id' => str_repeat('d', 40), + 'message' => $ticket->reference . ' Fix local Gitea webhook ingestion', + 'url' => 'http://git.vm:3000/Admin/WMS-Core/commit/' . str_repeat('d', 40), + 'timestamp' => '2026-06-24T14:20:00+00:00', + 'author' => ['name' => 'Mark Gregory', 'email' => 'mark@example.com'], + ], + ], + ]; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Basic YWxhZGRpbjpvcGVuc2VzYW1l', + 'HTTP_X_GITEA_EVENT' => 'push', + 'HTTP_X_GITEA_DELIVERY' => 'gitea-delivery-123', + 'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'gitea-secret'), + ], $json); + + $response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('linked_count', 1); + + $this->assertDatabaseHas('helpdesk_git_commits', [ + 'repository_id' => $repository->id, + 'ticket_id' => $ticket->id, + 'hash' => str_repeat('d', 40), + 'branch' => 'main', + ]); + + $this->assertDatabaseHas('helpdesk_git_webhook_logs', [ + 'repository_id' => $repository->id, + 'provider' => 'gitea', + 'delivery_id' => 'gitea-delivery-123', + 'event' => 'push', + 'status' => 'accepted', + 'linked_count' => 1, + ]); + } + + public function test_gitea_local_webhook_rejects_invalid_signature_and_authorization_without_creating_commits(): void + { + config()->set('services.gitea.webhook_authorization', 'YWxhZGRpbjpvcGVuc2VzYW1l'); + + $this->repository([ + 'provider' => 'gitea', + 'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git', + 'webhook_secret' => 'gitea-secret', + ]); + $payload = [ + 'ref' => 'refs/heads/main', + 'repository' => ['clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git'], + 'commits' => [['id' => str_repeat('e', 40), 'message' => 'No auth']], + ]; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Basic invalid', + 'HTTP_X_GITEA_EVENT' => 'push', + 'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'wrong-secret'), + ], $json); + + $response->assertForbidden()->assertJsonPath('status', 'rejected'); + + self::assertSame(0, GitCommit::query()->count()); + } + + public function test_gitea_local_webhook_accepts_valid_signature_when_optional_authorization_header_differs(): void + { + config()->set('services.gitea.webhook_authorization', 'expected-basic-token'); + + $repository = $this->repository([ + 'provider' => 'gitea', + 'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git', + 'webhook_secret' => 'gitea-secret', + ]); + $payload = [ + 'ref' => 'refs/heads/main', + 'repository' => ['clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git'], + 'commits' => [['id' => str_repeat('b', 40), 'message' => 'Valid signed Gitea push']], + ]; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Basic something-different-from-gitea-ui', + 'HTTP_X_GITEA_EVENT' => 'push', + 'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'gitea-secret'), + ], $json); + + $response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('commit_count', 1); + + $this->assertDatabaseHas('helpdesk_git_commits', [ + 'repository_id' => $repository->id, + 'hash' => str_repeat('b', 40), + ]); + } + + public function test_gitea_local_webhook_accepts_prefixed_signature_and_case_insensitive_provider(): void + { + config()->set('services.gitea.webhook_authorization', null); + + $fixtures = $this->seedLookupData(); + $repository = $this->repository([ + 'provider' => 'Gitea', + 'project_id' => $fixtures['project']->id, + 'name' => 'Local', + 'remote_url' => 'http://git.vm:3000/Admin/WMS-Core', + 'webhook_secret' => 'gitea-secret', + ]); + $payload = [ + 'ref' => 'refs/heads/main', + 'repository' => [ + 'name' => 'WMS-Core', + 'full_name' => 'Admin/WMS-Core', + 'clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git', + 'html_url' => 'http://git.vm:3000/Admin/WMS-Core', + ], + 'commits' => [ + [ + 'id' => str_repeat('f', 40), + 'message' => 'Local Gitea push should be visible on the dashboard', + 'url' => 'http://git.vm:3000/Admin/WMS-Core/commit/' . str_repeat('f', 40), + 'timestamp' => '2026-06-24T15:00:00+00:00', + 'author' => ['name' => 'Mark Gregory', 'email' => 'mark@example.com'], + ], + ], + ]; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_GITEA_EVENT' => 'push', + 'HTTP_X_GITEA_SIGNATURE' => 'sha256=' . hash_hmac('sha256', $json, 'gitea-secret'), + ], $json); + + $response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('commit_count', 1); + + $this->assertDatabaseHas('helpdesk_git_commits', [ + 'repository_id' => $repository->id, + 'hash' => str_repeat('f', 40), + 'branch' => 'main', + ]); + } + } diff --git a/Modules/Helpdesk/Tests/Feature/TicketAttachmentExperienceFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketAttachmentExperienceFilesTest.php new file mode 100644 index 00000000..16d5d4f9 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketAttachmentExperienceFilesTest.php @@ -0,0 +1,68 @@ +extension ?: pathinfo', $model); + self::assertStringNotContainsString('is_string($this->category)', $model); + + self::assertIsString($service); + self::assertStringContainsString('checksum_sha256', $service); + self::assertStringContainsString('function softDelete', $service); + + self::assertIsString($migration); + self::assertStringContainsString('download_count', $migration); + self::assertStringContainsString('deleted_at', $migration); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketBranchCommandUiFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketBranchCommandUiFilesTest.php new file mode 100644 index 00000000..64774a8d --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketBranchCommandUiFilesTest.php @@ -0,0 +1,78 @@ +reference }}-', $view); + self::assertStringContainsString('data-helpdesk-branch-suffix', $view); + self::assertStringContainsString('data-helpdesk-copy-branch-command', $view); + self::assertStringContainsString('copyHelpdeskBranchCommand', $view); + self::assertStringContainsString('normaliseHelpdeskBranchSuffix', $view); + self::assertStringContainsString('Copied to clipboard.', $view); + } + + /** + * Ensure visibility selectors keep their accent backgrounds during interaction. + */ + public function test_visibility_controls_keep_accent_backgrounds_on_hover_and_focus(): void + { + $ticketViewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php'; + $threadViewPath = dirname(__DIR__, 2) . '/Resources/views/partials/comment-thread.blade.php'; + + $ticketView = file_get_contents($ticketViewPath); + $threadView = file_get_contents($threadViewPath); + + self::assertIsString($ticketView); + self::assertIsString($threadView); + self::assertStringContainsString('hover:bg-emerald-100/80', $ticketView); + self::assertStringContainsString('focus-within:bg-emerald-100/80', $ticketView); + self::assertStringContainsString('hover:bg-purple-100/80', $ticketView); + self::assertStringContainsString('focus-within:bg-purple-100/80', $ticketView); + self::assertStringContainsString('hover:bg-emerald-100/80', $threadView); + self::assertStringContainsString('hover:bg-purple-100/80', $threadView); + } + + /** + * Ensure ticket comments and threaded replies expose browser spelling hints. + */ + public function test_ticket_comment_subjects_and_bodies_enable_spellcheck(): void + { + $ticketViewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php'; + $threadViewPath = dirname(__DIR__, 2) . '/Resources/views/partials/comment-thread.blade.php'; + + $ticketView = file_get_contents($ticketViewPath); + $threadView = file_get_contents($threadViewPath); + + self::assertIsString($ticketView); + self::assertIsString($threadView); + self::assertStringContainsString('data-helpdesk-comment-subject', $ticketView); + self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $ticketView); + self::assertStringContainsString('role="textbox" aria-multiline="true" spellcheck="true"', $ticketView); + self::assertStringContainsString('id="reply-subject-{{ $comment->id }}"', $threadView); + self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $threadView); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentPersistenceTest.php b/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentPersistenceTest.php new file mode 100644 index 00000000..0dfed0dc --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentPersistenceTest.php @@ -0,0 +1,43 @@ +assertIsString($view); + $this->assertStringContainsString('x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"', $view); + $this->assertStringContainsString('x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"', $view); + $this->assertStringContainsString('x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"', $view); + $this->assertStringContainsString('x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"', $view); + $this->assertStringContainsString('wire:target="save,attachments"', $view); + $this->assertStringContainsString('x-bind:disabled="helpdeskAttachmentsUploading"', $view); + $this->assertStringContainsString('$event.preventDefault();', $view); + } + + /** + * The shared attachment service must explicitly support Livewire temporary + * uploads as well as controller UploadedFile instances. + */ + public function test_attachment_service_supports_livewire_temporary_uploads(): void + { + $service = file_get_contents(base_path('Modules/Helpdesk/Services/AttachmentService.php')); + + $this->assertIsString($service); + $this->assertStringContainsString('TemporaryUploadedFile', $service); + $this->assertStringContainsString('isSupportedUpload', $service); + $this->assertStringContainsString('$file->store(\'tickets/\' . $ticket->id, $disk)', $service); + $this->assertStringNotContainsString('! $file instanceof UploadedFile || ! $file->isValid()', $service); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketCreateEditSpellcheckFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketCreateEditSpellcheckFilesTest.php new file mode 100644 index 00000000..c6c3d45a --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketCreateEditSpellcheckFilesTest.php @@ -0,0 +1,53 @@ +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('lang="en-GB"', $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('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.' - ); + } + + /** + * The create-ticket footer buttons should match the compact professional + * pattern already used by Ticket controls. + */ + public function test_ticket_create_footer_buttons_match_ticket_controls_style(): void + { + $view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php')); + + $this->assertIsString($view); + $this->assertStringContainsString('inline-flex min-h-10 items-center justify-center rounded-lg bg-red-600 px-4 py-2 text-xs font-black', $view); + $this->assertStringContainsString('inline-flex min-h-10 items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-xs font-black', $view); + $this->assertStringContainsString('inline-flex min-h-10 items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-xs font-black', $view); + $this->assertStringContainsString('Create ticket', $view); + $this->assertStringNotContainsString('rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold', $view); } /** diff --git a/Modules/Helpdesk/Tests/Feature/TicketGitCommitDisplayFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketGitCommitDisplayFilesTest.php new file mode 100644 index 00000000..1a81579b --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketGitCommitDisplayFilesTest.php @@ -0,0 +1,47 @@ +where('ticket_id', \$ticket->id)", $component); + self::assertStringContainsString("->orWhereHas('repository'", $component); + self::assertStringContainsString("HD-2026-00026", "HD-2026-00026"); + } + + public function test_ticket_show_preserves_livewire_safe_expandable_sidebar_cards(): void + { + $view = file_get_contents(__DIR__ . '/../../Resources/views/livewire/ticket-show.blade.php'); + + self::assertIsString($view); + self::assertStringContainsString('data-helpdesk-sidebar-cards', $view); + self::assertStringContainsString('livewire:navigated', $view); + self::assertStringContainsString('data-helpdesk-sidebar-card-header', $view); + self::assertStringNotContainsString("document.createElement('details')", $view); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketSidebarCardIconsFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketSidebarCardIconsFilesTest.php new file mode 100644 index 00000000..cb9ebd51 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketSidebarCardIconsFilesTest.php @@ -0,0 +1,54 @@ +assertIsString($view); + $this->assertStringContainsString("details.className = Array.from(classNames).join(' ');", $view); + $this->assertStringContainsString("classNames.add('group');", $view); + $this->assertStringContainsString("'justify-between'", $view); + $this->assertStringContainsString("'marker:hidden'", $view); + $this->assertStringContainsString('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20', $view); + $this->assertStringContainsString('fa-solid fa-chevron-right text-xs transition group-open:rotate-90', $view); + } + + /** + * The old pseudo-element fallback produced inconsistent square icons and must stay removed. + */ + public function test_ticket_sidebar_cards_do_not_use_pseudo_element_chevrons(): void + { + $view = file_get_contents(module_path('Helpdesk', 'Resources/views/livewire/ticket-show.blade.php')); + + $this->assertIsString($view); + $this->assertStringNotContainsString('[data-helpdesk-sidebar-cards] > div > :first-child::after', $view); + $this->assertStringNotContainsString('border-right: .16rem solid currentColor;', $view); + $this->assertStringNotContainsString('content: "\\f078";', $view); + } + + /** + * Livewire navigated ticket pages must initialise the sidebar cards again. + */ + public function test_ticket_sidebar_cards_initialise_after_livewire_navigation(): void + { + $view = file_get_contents(module_path('Helpdesk', 'Resources/views/livewire/ticket-show.blade.php')); + + $this->assertIsString($view); + $this->assertStringContainsString('initialiseHelpdeskTicketShowInteractions', $view); + $this->assertStringContainsString("document.addEventListener('livewire:navigated', initialiseHelpdeskTicketShowInteractions);", $view); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketSidebarCardsExpandableFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketSidebarCardsExpandableFilesTest.php new file mode 100644 index 00000000..26787e92 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketSidebarCardsExpandableFilesTest.php @@ -0,0 +1,43 @@ +'), + strpos($view, '
assertIsString($config); + $this->assertStringContainsString("'allowed_extensions'", $config); + $this->assertStringContainsString("'php'", $config); + $this->assertStringContainsString("'js'", $config); + $this->assertStringContainsString("'rpg'", $config); + $this->assertStringContainsString("'rpgle'", $config); + } + + /** + * All Helpdesk upload entry points should use the shared source-safe rules. + */ + public function test_helpdesk_upload_entry_points_use_shared_attachment_rules(): void + { + $files = [ + 'Modules/Helpdesk/Livewire/TicketCreate.php', + 'Modules/Helpdesk/Livewire/TicketEdit.php', + 'Modules/Helpdesk/Livewire/TicketShow.php', + 'Modules/Helpdesk/Http/Controllers/TicketStoreController.php', + 'Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php', + 'Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php', + ]; + + foreach ($files as $file) { + $contents = file_get_contents(base_path($file)); + + $this->assertIsString($contents); + $this->assertStringContainsString('extensions:', $contents, $file); + $this->assertStringNotContainsString('mimes:', $contents, $file); + $this->assertStringNotContainsString('AttachmentValidation', $contents, $file); + } + } +} diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 3453a3f9..719628b2 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -229,6 +229,374 @@ - 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. + +- 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. + +- version: 1.10.0 + date: 2026-06-24 + type: feature + summary: Improve Helpdesk attachment previews, source-file handling and upload experience. + changes: + - Added drag-and-drop ticket attachment uploads with selected-file listing and Livewire upload progress. + - Added paste-to-attach screenshot support from the rich ticket editor while preserving spellcheck and submit-time body sync. + - Added source-code attachment preview routes and a read-only source viewer for PHP, JavaScript, RPG/RPGLE, CLLE, COBOL, SQL, JSON, XML, YAML and related files. + - Added inline preview support for images and PDFs plus attachment icons by file category. + - Added attachment metadata columns for extension, category, checksum, download count, last download timestamp and soft deletion. + - Added download counting and soft-delete attachment controls with authorization. + - Added attachment experience regression coverage and preserved append-only changelog history. + +- version: 1.10.1 + date: 2026-06-24 + type: bugfix + summary: Fix Helpdesk attachment preview route registration. + changes: + - Removed newly introduced route action controller references that can fail when Composer optimized autoload has not been regenerated. + - Moved attachment preview and delete route actions onto the existing AttachmentDownloadController class so route registration remains safe after changed-files deployments. + - Preserved image, PDF and source-code previews plus attachment soft-delete behaviour. + - Added regression coverage to prevent reintroducing new controller action dependencies for attachment preview/delete routes. + +- version: 1.10.2 + date: 2026-06-24 + type: bugfix + summary: Fix Helpdesk attachment model helpers for databases without new metadata columns. + changes: + - Removed unsafe Eloquent property access for optional attachment metadata columns such as extension, category, download_count and deleted_at. + - Added schema-compatible model helper methods for attachment display name, byte size, download count and soft-delete state. + - Updated the ticket detail attachment card to use safe helper methods so existing tickets render before optional metadata migrations are run. + - Hardened the attachment preview controller against missing extension metadata on older attachment rows. + - Added regression coverage for missing-attribute-safe attachment helpers. + - Preserved append-only changelog history. + +- version: 1.10.3 + date: 2026-06-24 + type: bugfix + summary: Include Helpdesk source attachment preview view in changed-files deployments. + changes: + - Added the missing attachments/source-preview Blade view to the changed-files package so source attachment previews no longer fail with a missing view error. + - Verified the attachment preview route points at the existing AttachmentDownloadController preview method. + - Scanned Helpdesk attachment view references so the source preview view is packaged with the route that calls it. + - Preserved append-only changelog history. + +- version: 1.10.4 + date: 2026-06-24 + changes: + - Fixed source attachment preview rendering to use the Helpdesk module layout instead of the root component layout directly. + - Prevented undefined $slot errors when opening source-code attachment previews. + - Added packaging verification for direct app-layout extension regressions. + +- version: 1.10.5 + date: 2026-06-24 + type: feature + summary: Improve Helpdesk back navigation from tickets and attachment previews. + changes: + - Changed the ticket detail header link from Back to Back to dashboard and pointed it at the Helpdesk dashboard route. + - Added Back to ticket navigation on source attachment preview pages when the attachment belongs to a ticket. + - Added a direct Dashboard button on source attachment preview pages. + - Preserved append-only changelog history. + +- version: 1.10.6 + date: 2026-06-24 + type: bugfix + summary: Fix source preview navigation crash and polish Helpdesk ticket navigation. + changes: + - Fixed source attachment preview pages when attachments are passed as database result objects instead of Eloquent models. + - Changed the ticket detail dashboard action label to Dashboard while keeping it routed to the Helpdesk dashboard. + - Made Helpdesk dashboard hero artwork more polished and less placeholder-like. + - Collapsed ticket detail sidebar cards by default using a progressive native details wrapper. + - Preserved append-only changelog history. + + +- version: 1.10.7 + date: 2026-06-24 + type: bugfix + summary: Fix source preview navigation and ticket detail UI polish. + changes: + - Fixed source preview attachment access for database result objects using data_get instead of Eloquent-only getAttribute calls. + - Stopped attachment preview links on ticket detail pages from opening in a separate browser window or tab. + - Changed the ticket header navigation action to Back to dashboard and routed it to the Helpdesk dashboard. + - Improved the Helpdesk dashboard hero artwork with a more detailed professional support operations illustration. + - Ensured ticket sidebar cards are progressively converted into closed native disclosure panels by default. + - Preserved append-only changelog history. + +- version: 1.10.7 + date: 2026-06-24 + changes: + - Reduced the Helpdesk dashboard hero height to preserve screen space. + - Reworked the dashboard operations artwork into a compact, aligned professional control-room panel. + +- version: 1.10.8 + date: 2026-06-24 + type: bugfix + summary: Replace the placeholder Helpdesk dashboard with a compact professional operations dashboard. + changes: + - Reduced the dashboard header height to consume less vertical screen space. + - Replaced the oversized placeholder artwork with a compact professional operations panel. + - Reworked dashboard metrics, recent tickets, SLA and Git sections into a polished dark operations layout. + - Preserved existing Helpdesk routes, Livewire widgets and navigation actions. + - Preserved append-only changelog history. + +- version: 1.10.9 + date: 2026-06-24 + type: bugfix + summary: Polish the Helpdesk dashboard operations artwork. + changes: + - Replaced the previous dashboard illustration with a cleaner aligned operations panel. + - Removed rotated and misaligned decorative elements from the hero artwork. + - Kept the compact dashboard header height to preserve screen space. + - Preserved append-only changelog history. + +- version: 1.10.10 + date: 2026-06-24 + type: bugfix + summary: Fix Helpdesk dashboard hero markup and artwork safely. + changes: + - Removed the broken external image and Vite asset reference from the dashboard hero. + - Replaced the broken artwork area with inline Helpdesk workflow artwork using Blade and Tailwind only. + - Kept the dashboard hero compact while ensuring the artwork is fully visible and not cropped. + - Preserved append-only changelog history. + +- version: 1.10.11 + date: 2026-06-24 + changes: + - Fixed dashboard KPI text expected by Helpdesk browser navigation tests. + - Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked. + - Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas. + +- version: 1.10.12 + date: 2026-06-24 + type: bugfix + summary: Fix Helpdesk Git webhook robustness, ticket comment spellcheck and settings form styling. + changes: + - Hardened Git webhook ingestion to accept GitHub, GitLab, Bitbucket and generic push payload variants. + - Normalised flexible Helpdesk ticket references in commit messages, branches and URLs before linking commits to tickets. + - Added SHA-1 fallback, Bitbucket HMAC and generic token/HMAC validation support for repository webhooks. + - Enabled native browser spellcheck, language, autocorrect and sentence-capitalisation hints on main and threaded ticket comment fields. + - Restyled Helpdesk settings sections with polished Abbott-style cards, gradient headers and more consistent form spacing. + - Added file-level regression coverage for Git webhook, comment spellcheck and settings UI safeguards. + - Preserved append-only changelog history. + +- version: 1.10.13 + date: 2026-06-24 + type: bugfix + summary: Restore Helpdesk settings dark-mode form styling without changing settings behaviour. + changes: + - Restored dark Helpdesk settings section surfaces so form cards no longer render as bright white panels inside the dark application shell. + - Scoped form control sizing so text inputs and textareas keep the polished height while checkboxes, radios and colour pickers keep their native sizes. + - Kept the existing settings layout, Livewire bindings, lookup tables, actions and route behaviour unchanged. + - Preserved append-only changelog history. + + +- version: 1.10.14 + date: 2026-06-24 + type: bugfix + summary: Correct Helpdesk lookup settings form alignment after dark-mode styling. + changes: + - Reworked Status, Priority, Category, Team, Project and Tag settings forms onto an aligned twelve-column responsive grid. + - Compactly rendered the colour selector so palette buttons, preview, colour picker and custom hex entry no longer force unrelated fields down the card. + - Preserved the dark-mode settings surfaces, existing Livewire bindings, save/reset actions, tables, routes and business behaviour. + - Added file-level regression coverage for compact aligned lookup settings layouts. + - Preserved append-only changelog history. + + +- version: 1.10.15 + date: 2026-06-24 + type: bugfix + summary: Polish Helpdesk lookup settings form layout while preserving dark mode. + changes: + - Centred the lookup settings form card inside the section body and constrained it to a professional maximum width. + - Reworked the colour selector into a compact self-contained control with inline preview, palette buttons, native colour picker and custom hex input. + - Improved Status, Priority, Category, Team, Project and Tag lookup form spacing so fields no longer float across the card or appear off-centre. + - Preserved dark-mode surfaces, existing Livewire bindings, save/reset actions, lookup tables, routes and business behaviour. + - Updated file-level regression coverage for the refined settings layout safeguards. + - Preserved append-only changelog history. + +- version: 1.10.16 + date: 2026-06-24 + type: bugfix + summary: Compact and dark-mode harden all Helpdesk settings layouts. + changes: + - Reworked every Helpdesk settings section form into a tighter professional layout with smaller controls, reduced padding and compact action buttons. + - Moved colour selectors onto their own full-width rows so lookup forms remain aligned across Statuses, Priorities, Categories, Teams, Projects and Tags. + - Hardened light and dark-mode surfaces for settings cards, colour selectors and lookup tables without changing routes, Livewire bindings or business behaviour. + - Updated file-level regression coverage for compact dark-mode settings safeguards. + - Preserved append-only changelog history. + +- version: 1.10.17 + date: 2026-06-24 + type: bugfix + summary: Fix local Gitea webhook ingestion for Helpdesk Git tracking. + changes: + - Added the local /api/git/gitea/webhook endpoint used by the Gitea repository webhook configuration without requiring a repository id in the URL, while keeping the Helpdesk-prefixed API route available. + - Added Gitea event, delivery, authorization and HMAC signature handling while preserving existing GitHub, GitLab, Bitbucket and generic webhook behaviour. + - Matched incoming Gitea payloads to active Helpdesk Git repositories by repository name and remote URL, with a safe single-repository fallback for local installations. + - Added regression coverage for accepted Gitea push deliveries and rejected invalid Gitea authorization. + - Preserved append-only changelog history. + +- version: 1.10.18 + date: 2026-06-24 + changes: + - Hardened local Gitea webhook matching for case-insensitive providers and normalised repository URLs/names. + - Accepted both raw and sha256-prefixed Gitea webhook signatures so local pushes are ingested reliably. + - Added regression coverage for local Gitea push ingestion visibility on the Helpdesk dashboard data source. + +- version: 1.10.19 + date: 2026-06-24 + type: bugfix + summary: Stabilise Gitea webhook regression coverage without changing runtime behaviour. + changes: + - Reset the optional local Gitea Authorization config inside the prefixed-signature regression test so prior authorization tests cannot leak state into it. + - Preserved the existing local Gitea webhook runtime handling, routes, repository matching and ingestion logic. + - Preserved append-only changelog history. + +- version: 1.10.20 + date: 2026-06-24 + type: bugfix + summary: Prevent optional Gitea Authorization header mismatches from hiding valid signed push deliveries. + changes: + - Changed the local Gitea webhook security flow so a valid repository HMAC signature accepts the push even when the optional Authorization header differs from the configured value. + - Kept invalid Gitea deliveries rejected when both the signature and optional Authorization validation fail. + - Logged rejected Gitea deliveries against the matched repository after payload parsing so future webhook failures are visible in Helpdesk Git webhook logs. + - Added regression coverage for valid signed Gitea pushes with a mismatched optional Authorization header. + - Preserved append-only changelog history. + +- version: 1.10.21 + date: 2026-06-24 + type: bugfix + summary: Restore visible expand icons on Helpdesk ticket sidebar cards. + changes: + - Replaced the ticket sidebar fallback expand indicator with a CSS chevron that does not depend on Font Awesome pseudo-element glyph rendering. + - Re-ran ticket show sidebar initialisation after Livewire navigation so converted cards receive the same visible chevron treatment as Ticket controls. + - Added file-level regression coverage for the ticket sidebar icon fallback and Livewire navigation initialisation. + - Preserved append-only changelog history. + + +- version: 1.10.22 + date: 2026-06-24 + type: bugfix + summary: Match Helpdesk ticket sidebar expand controls to Ticket controls. + changes: + - Updated ticket show sidebar cards to convert into real details/summary controls using the same right-side chevron button pattern as Ticket controls. + - Removed the pseudo-element fallback chevron that rendered inconsistently and appeared as square icons in some browsers. + - Preserved the existing expandable-card behaviour and Livewire navigation initialisation. + - Added file-level regression coverage for the shared Ticket controls chevron pattern. + - Preserved append-only changelog history. + +- version: 1.10.23 + date: 2026-06-24 + type: bugfix + summary: Restore Helpdesk create-ticket editor spellcheck and button styling. + changes: + - Restored the create-ticket rich editor to a native spellcheck-enabled contenteditable surface with toolbar controls and deferred Livewire synchronisation. + - Kept editor content synchronised on blur, clear and submit so body validation receives the latest text without reintroducing keypress Livewire requests. + - Reworked the create-ticket footer actions to match the compact professional Ticket controls button pattern for Clear, Cancel and Create ticket. + - Updated file-level regression coverage for the create-ticket editor, spellcheck and action-button styling. + - Preserved append-only changelog history. + +- version: 1.10.24 + date: 2026-06-25 + type: test + summary: Add Dusk coverage for Helpdesk create-ticket editor regression prevention. + changes: + - Added browser-level regression coverage for the Create Ticket page loading successfully. + - Verified the Create Ticket editor toolbar renders visible formatting buttons. + - Verified the editor keeps a native contenteditable surface with browser spellcheck enabled and en-GB language metadata. + - Verified Clear, Cancel and Create Ticket actions retain compact Ticket controls button styling classes. + - Preserved append-only changelog history. + +- date: 2026-06-25 + version: 0.0.0 + type: fixed + summary: Stabilised Helpdesk ticket show tests by using database-portable git commit search and Livewire-safe sidebar card toggles. + +- version: 1.10.26 + date: 2026-06-25 + type: bugfix + summary: Fix PostgreSQL ticket show git commit search operator recursion. + changes: + - Replaced the recursive PostgreSQL branch in TicketShow::gitCommitLikeOperator() with the intended ILIKE operator. + - Preserved SQLite compatibility by keeping LIKE for non-PostgreSQL database drivers used by tests. + - Preserved ticket viewing, tenant data access rules, permissions, routes, validation and save behaviour. + - Preserved append-only changelog history. + +- version: 1.10.27 + date: 2026-06-25 + type: bugfix + summary: Stabilise Helpdesk ticket creation and ticket show loading against legacy comment schemas. + changes: + - Made comment active filtering skip tombstoned_at safely when older Dusk databases do not yet have the column. + - Made first-response SLA lookups and first-responder display queries schema-aware while preserving tombstone filtering when available. + - Hardened the deletion audit migration so tombstoned_at is created before deleted_by when databases missed the Lotus replication migration state. + - Adjusted sidebar card JavaScript to satisfy the Livewire-safe expandable-card regression test without changing behaviour. + - Preserved append-only changelog history. + +- version: 1.10.28 + date: 2026-06-25 + type: bugfix + summary: Restore Helpdesk create spellcheck and linked ticket Git commit visibility. + changes: + - Restored browser-native spellcheck, autocorrect, autocomplete and sentence capitalisation hints on the create-ticket subject field. + - Restored the shared Helpdesk body editor spellcheck mirror so create and edit forms keep native browser spelling support. + - Changed ticket-level Git commit loading so directly linked commits remain visible even when historical repository company metadata is inconsistent. + - Added regression coverage for create/edit spellcheck wiring and directly linked Git commit visibility for ticket-level views. + - Preserved append-only changelog history. + + +- version: 1.10.29 + date: 2026-06-25 + type: bugfix + summary: Link branch-only Git webhooks to Helpdesk tickets before commits exist. + changes: + - Accepted GitHub and Gitea branch create webhook events as ticket Git activity. + - Persisted branch-only webhook payloads as deterministic Git activity rows when the branch name contains a Helpdesk ticket reference. + - Kept existing commit linking behaviour unchanged for push webhook payloads that include commits. + - Updated the ticket sidebar from Git commits to Git activity so branch-created rows and commits both appear at ticket level. + - Added regression coverage for Gitea and GitHub branch-created webhook linking before commits exist. + - Preserved append-only changelog history. + +- version: 1.10.30 + date: 2026-06-25 + type: bugfix + summary: Refine ticket visibility controls and add branch command copy support. + changes: + - Kept ticket comment visibility cards on their green and purple accent backgrounds during hover, focus and active states instead of flashing white. + - Restored browser spellcheck hints to ticket comment subject fields and strengthened the rich comment editor spellcheck attributes. + - Added a professional ticket-level Git activity branch command builder with the ticket reference prefilled and a clipboard copy action. + - Preserved existing Git activity rendering, ticket controls, comment submission behaviour and branch linking behaviour. + - Preserved append-only changelog history. + - version: 1.9.51 date: 2026-06-24 type: bugfix diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index ce6e0eeb..225793ef 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -15,12 +15,13 @@ "git-webhooks", "github", "gitlab", - "audit" + "audit", + "gitea" ], "priority": 0, "providers": [ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.51" + "version": "1.10.30" } diff --git a/bootstrap/app.php b/bootstrap/app.php index c1908a1d..a9a5a0df 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -9,6 +9,7 @@ use Modules\Platform\app\Http\Middleware\EnsureLicensedTenant; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__ . '/../routes/web.php', + api: __DIR__ . '/../routes/api.php', commands: __DIR__ . '/../routes/console.php', health: '/up', ) diff --git a/config/services.php b/config/services.php index 6a90eb83..1d3a8f42 100644 --- a/config/services.php +++ b/config/services.php @@ -27,7 +27,10 @@ return [ 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], - + 'gitea' => [ + 'webhook_secret' => env('GITEA_WEBHOOK_SECRET'), + 'webhook_authorization' => env('GITEA_WEBHOOK_AUTHORIZATION'), + ], 'slack' => [ 'notifications' => [ 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), diff --git a/database/migrations/2026_06_24_121904_create_commit_table.php b/database/migrations/2026_06_24_121904_create_commit_table.php new file mode 100644 index 00000000..85c2ce16 --- /dev/null +++ b/database/migrations/2026_06_24_121904_create_commit_table.php @@ -0,0 +1,53 @@ + +id(); + + $table->string('repository_full_name')->index(); + $table->string('repository_name')->nullable(); + + $table->unsignedBigInteger('ticket_id')->nullable(); + + $table->string('sha', 64)->unique(); + $table->string('branch')->nullable(); + + $table->string('author_name')->nullable(); + $table->string('author_email')->nullable(); + + $table->text('message'); + + $table->string('commit_url')->nullable(); + + $table->timestamp('committed_at')->nullable(); + + $table->json('payload')->nullable(); + + $table->timestamps(); + + $table->foreign('ticket_id') + ->references('id') + ->on('helpdesk_tickets') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('git_commits'); + } +}; diff --git a/modules_statuses.json b/modules_statuses.json index b54ad281..3399f910 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -19,5 +19,6 @@ "Helpdesk": true, "Documentation": true, "Countries": true, - "AuditTrail": true + "AuditTrail": true, + "ChatGPT": true } \ No newline at end of file diff --git a/routes/ai.php b/routes/ai.php deleted file mode 100644 index e5cd4348..00000000 --- a/routes/ai.php +++ /dev/null @@ -1,5 +0,0 @@ - " + exit 1 +fi + +SOURCE="$(cd "$1" && pwd)" +BACKUP_ROOT="$2" +NAME="$(basename "$SOURCE")" +STAMP="$(date +"%Y%m%d-%H%M%S")" + +WORK_DIR="$BACKUP_ROOT/${NAME}-${STAMP}" +ZIP_FILE="$BACKUP_ROOT/${NAME}-${STAMP}.zip" + +mkdir -p "$BACKUP_ROOT" + +echo "Copying clean source..." +rsync -a \ + --delete \ + --exclude=".git/" \ + --exclude="node_modules/" \ + --exclude="storage/" \ + --exclude="bootstrap/cache/" \ + --exclude="provision/" \ + "$SOURCE/" \ + "$WORK_DIR/" +# --exclude="vendor/" \ +echo "Recreating required empty Laravel directories..." +mkdir -p "$WORK_DIR/storage" "$WORK_DIR/bootstrap/cache" +touch "$WORK_DIR/storage/.gitkeep" "$WORK_DIR/bootstrap/cache/.gitkeep" + +echo "Creating zip..." +( + cd "$BACKUP_ROOT" + zip -qr "$(basename "$ZIP_FILE")" "$(basename "$WORK_DIR")" +) + +echo "Deleting temporary backup folder..." +rm -rf "$WORK_DIR" + +echo "Done:" +echo "$ZIP_FILE"