diff --git a/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php b/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php
new file mode 100644
index 00000000..e95ad36f
--- /dev/null
+++ b/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php
@@ -0,0 +1,63 @@
+error('No active ChatGPT/OpenAI API key is stored.');
+ return self::FAILURE;
+ }
+
+ $service = new OpenAiFileService((string) $setting->api_key);
+
+ try {
+ if ((bool) $this->option('dry-run')) {
+ $files = $service->listFiles();
+
+ $this->info('Found ' . count($files) . ' OpenAI API file(s).');
+
+ foreach ($files as $file) {
+ $this->line(sprintf(
+ '- %s %s',
+ (string) ($file['id'] ?? 'unknown'),
+ (string) ($file['filename'] ?? 'unnamed')
+ ));
+ }
+
+ return self::SUCCESS;
+ }
+
+ $result = $service->deleteAllFiles();
+
+ $this->info("Listed: {$result['listed']}");
+ $this->info("Deleted: {$result['deleted']}");
+ $this->info("Failed: {$result['failed']}");
+
+ foreach ($result['failures'] as $failure) {
+ $this->error($failure['file_id'] . ': ' . $failure['error']);
+ }
+
+ return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE;
+ } catch (Throwable $exception) {
+ $this->error($exception->getMessage());
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php b/Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php
new file mode 100644
index 00000000..7380e84a
--- /dev/null
+++ b/Modules/ChatGPT/Database/Migrations/2026_06_24_000001_create_chatgpt_api_settings_table.php
@@ -0,0 +1,30 @@
+id();
+ $table->string('name')->default('OpenAI API Key');
+ $table->text('api_key');
+ $table->boolean('is_active')->default(true)->index();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('chatgpt_api_settings');
+ }
+};
diff --git a/Modules/ChatGPT/Http/Controllers/ChatGPTController.php b/Modules/ChatGPT/Http/Controllers/ChatGPTController.php
index 726b6848..a207759e 100644
--- a/Modules/ChatGPT/Http/Controllers/ChatGPTController.php
+++ b/Modules/ChatGPT/Http/Controllers/ChatGPTController.php
@@ -3,77 +3,80 @@
namespace Modules\ChatGPT\Http\Controllers;
use Illuminate\Contracts\Support\Renderable;
-use Illuminate\Http\Request;
+use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
+use Modules\ChatGPT\Http\Requests\StoreChatGptApiSettingRequest;
+use Modules\ChatGPT\Http\Requests\UpdateChatGptApiSettingRequest;
+use Modules\ChatGPT\Models\ChatGptApiSetting;
class ChatGPTController extends Controller
{
/**
- * Display a listing of the resource.
- * @return Renderable
+ * Display the ChatGPT module settings page.
*/
- public function index()
+ public function index(): Renderable
{
- return view('chatgpt::index');
+ return view('chatgpt::index', [
+ 'settings' => ChatGptApiSetting::query()->latest('id')->get(),
+ ]);
}
/**
- * Show the form for creating a new resource.
- * @return Renderable
+ * Store a new encrypted OpenAI API key setting.
*/
- public function create()
+ public function store(StoreChatGptApiSettingRequest $request): RedirectResponse
{
- return view('chatgpt::create');
+ $data = $request->validated();
+
+ ChatGptApiSetting::query()->update(['is_active' => false]);
+
+ ChatGptApiSetting::query()->create([
+ 'name' => $data['name'] ?: 'OpenAI API Key',
+ 'api_key' => $data['api_key'],
+ 'is_active' => (bool) ($data['is_active'] ?? true),
+ ]);
+
+ return redirect()
+ ->route('chatgpt.index')
+ ->with('status', 'OpenAI API key saved.');
}
/**
- * Store a newly created resource in storage.
- * @param Request $request
- * @return Renderable
+ * Update an encrypted OpenAI API key setting.
*/
- public function store(Request $request)
+ public function update(UpdateChatGptApiSettingRequest $request, ChatGptApiSetting $setting): RedirectResponse
{
- //
+ $data = $request->validated();
+
+ if ((bool) ($data['is_active'] ?? false)) {
+ ChatGptApiSetting::query()
+ ->whereKeyNot($setting->getKey())
+ ->update(['is_active' => false]);
+ }
+
+ $setting->name = $data['name'] ?: $setting->name;
+ $setting->is_active = (bool) ($data['is_active'] ?? false);
+
+ if (! empty($data['api_key'])) {
+ $setting->api_key = $data['api_key'];
+ }
+
+ $setting->save();
+
+ return redirect()
+ ->route('chatgpt.index')
+ ->with('status', 'OpenAI API key updated.');
}
/**
- * Show the specified resource.
- * @param int $id
- * @return Renderable
+ * Delete an encrypted OpenAI API key setting.
*/
- public function show($id)
+ public function destroy(ChatGptApiSetting $setting): RedirectResponse
{
- return view('chatgpt::show');
- }
+ $setting->delete();
- /**
- * Show the form for editing the specified resource.
- * @param int $id
- * @return Renderable
- */
- public function edit($id)
- {
- return view('chatgpt::edit');
- }
-
- /**
- * Update the specified resource in storage.
- * @param Request $request
- * @param int $id
- * @return Renderable
- */
- public function update(Request $request, $id)
- {
- //
- }
-
- /**
- * Remove the specified resource from storage.
- * @param int $id
- * @return Renderable
- */
- public function destroy($id)
- {
- //
+ return redirect()
+ ->route('chatgpt.index')
+ ->with('status', 'OpenAI API key deleted.');
}
}
diff --git a/Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php b/Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php
new file mode 100644
index 00000000..b57226ff
--- /dev/null
+++ b/Modules/ChatGPT/Http/Requests/StoreChatGptApiSettingRequest.php
@@ -0,0 +1,30 @@
+
+ */
+ public function rules(): array
+ {
+ return [
+ 'name' => ['nullable', 'string', 'max:255'],
+ 'api_key' => ['required', 'string', 'min:20', 'max:500'],
+ 'is_active' => ['nullable', 'boolean'],
+ ];
+ }
+}
diff --git a/Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php b/Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php
new file mode 100644
index 00000000..f1b8387a
--- /dev/null
+++ b/Modules/ChatGPT/Http/Requests/UpdateChatGptApiSettingRequest.php
@@ -0,0 +1,30 @@
+
+ */
+ public function rules(): array
+ {
+ return [
+ 'name' => ['nullable', 'string', 'max:255'],
+ 'api_key' => ['nullable', 'string', 'min:20', 'max:500'],
+ 'is_active' => ['nullable', 'boolean'],
+ ];
+ }
+}
diff --git a/Modules/ChatGPT/Models/ChatGptApiSetting.php b/Modules/ChatGPT/Models/ChatGptApiSetting.php
new file mode 100644
index 00000000..215a8320
--- /dev/null
+++ b/Modules/ChatGPT/Models/ChatGptApiSetting.php
@@ -0,0 +1,53 @@
+ 'encrypted',
+ 'is_active' => 'boolean',
+ ];
+
+ protected $hidden = [
+ 'api_key',
+ ];
+
+ /**
+ * Get the currently active OpenAI API setting.
+ */
+ public static function active(): ?self
+ {
+ return self::query()
+ ->where('is_active', true)
+ ->latest('id')
+ ->first();
+ }
+
+ /**
+ * Return a masked version of the stored API key for display.
+ */
+ public function maskedApiKey(): string
+ {
+ $key = (string) $this->api_key;
+
+ if ($key === '') {
+ return 'Not set';
+ }
+
+ return substr($key, 0, 7) . '...' . substr($key, -4);
+ }
+}
diff --git a/Modules/ChatGPT/Resources/views/index.blade.php b/Modules/ChatGPT/Resources/views/index.blade.php
index bc25acc3..83f2de0d 100644
--- a/Modules/ChatGPT/Resources/views/index.blade.php
+++ b/Modules/ChatGPT/Resources/views/index.blade.php
@@ -1,9 +1,171 @@
@extends('chatgpt::layouts.master')
@section('content')
-
Hello World
+
+
+
+
+
ChatGPT API Settings
+
+ Store the OpenAI API key used by the module command. Keys are encrypted at rest by Laravel.
+
+
-
- This view is loaded from module: {!! config('chatgpt.name') !!}
-
+
+ php artisan chatgpt:delete-api-files
+
+
+
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+ @if ($errors->any())
+
+
Unable to save the API key.
+
+ @foreach ($errors->all() as $error)
+ {{ $error }}
+ @endforeach
+
+
+ @endif
+
+
+
+
+
Stored API Keys
+
+
+
+
+
+ Name
+ Key
+ Active
+ Updated
+ Actions
+
+
+
+ @forelse ($settings as $setting)
+
+
+
+
+ {{ $setting->maskedApiKey() }}
+
+ @if ($setting->is_active)
+ Yes
+ @else
+ No
+ @endif
+
+ {{ $setting->updated_at?->format('d M Y H:i') }}
+
+
+
+ Update
+
+
+
+
+
+
+ @empty
+
+
+ No API keys have been stored yet.
+
+
+ @endforelse
+
+
+
+
+
@endsection
diff --git a/Modules/ChatGPT/Routes/web.php b/Modules/ChatGPT/Routes/web.php
index 5006d946..98f524e1 100644
--- a/Modules/ChatGPT/Routes/web.php
+++ b/Modules/ChatGPT/Routes/web.php
@@ -1,16 +1,14 @@
group(function() {
- Route::get('/', 'ChatGPTController@index');
-});
+Route::middleware(['web', 'auth'])
+ ->prefix('chatgpt')
+ ->name('chatgpt.')
+ ->group(function (): void {
+ Route::get('/', [ChatGPTController::class, 'index'])->name('index');
+ Route::post('/settings', [ChatGPTController::class, 'store'])->name('settings.store');
+ Route::put('/settings/{setting}', [ChatGPTController::class, 'update'])->name('settings.update');
+ Route::delete('/settings/{setting}', [ChatGPTController::class, 'destroy'])->name('settings.destroy');
+ });
diff --git a/Modules/ChatGPT/Services/OpenAiFileService.php b/Modules/ChatGPT/Services/OpenAiFileService.php
new file mode 100644
index 00000000..7077e44f
--- /dev/null
+++ b/Modules/ChatGPT/Services/OpenAiFileService.php
@@ -0,0 +1,121 @@
+>
+ */
+ public function listFiles(): array
+ {
+ $files = [];
+ $after = null;
+
+ do {
+ $query = ['limit' => 100];
+
+ if (is_string($after) && $after !== '') {
+ $query['after'] = $after;
+ }
+
+ $response = $this->client()
+ ->get(self::BASE_URL . '/files', $query);
+
+ if ($response->failed()) {
+ throw new RuntimeException('OpenAI file list request failed: ' . $response->body());
+ }
+
+ $payload = $response->json();
+ $batch = $payload['data'] ?? [];
+
+ foreach ($batch as $file) {
+ if (is_array($file)) {
+ $files[] = $file;
+ }
+ }
+
+ $hasMore = (bool) ($payload['has_more'] ?? false);
+ $after = $payload['last_id'] ?? null;
+ } while ($hasMore && is_string($after) && $after !== '');
+
+ return $files;
+ }
+
+ /**
+ * Delete one OpenAI API file by ID.
+ *
+ * @return array
+ */
+ public function deleteFile(string $fileId): array
+ {
+ $response = $this->client()
+ ->delete(self::BASE_URL . '/files/' . rawurlencode($fileId));
+
+ if ($response->failed()) {
+ throw new RuntimeException("OpenAI file delete request failed for {$fileId}: " . $response->body());
+ }
+
+ return $response->json() ?? [];
+ }
+
+ /**
+ * Delete every OpenAI API file visible to the configured API key.
+ *
+ * @return array{listed:int, deleted:int, failed:int, failures:array}
+ */
+ public function deleteAllFiles(): array
+ {
+ $files = $this->listFiles();
+ $deleted = 0;
+ $failures = [];
+
+ foreach ($files as $file) {
+ $fileId = (string) ($file['id'] ?? '');
+
+ if ($fileId === '') {
+ continue;
+ }
+
+ try {
+ $this->deleteFile($fileId);
+ $deleted++;
+ } catch (RuntimeException $exception) {
+ $failures[] = [
+ 'file_id' => $fileId,
+ 'error' => $exception->getMessage(),
+ ];
+ }
+ }
+
+ return [
+ 'listed' => count($files),
+ 'deleted' => $deleted,
+ 'failed' => count($failures),
+ 'failures' => $failures,
+ ];
+ }
+
+ /**
+ * Build the authenticated OpenAI HTTP client.
+ */
+ private function client(): PendingRequest
+ {
+ return Http::withToken($this->apiKey)
+ ->acceptJson()
+ ->timeout(60)
+ ->retry(2, 500);
+ }
+}
diff --git a/Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php b/Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php
new file mode 100644
index 00000000..d29326b5
--- /dev/null
+++ b/Modules/ChatGPT/Tests/Feature/ChatGptApiSettingTest.php
@@ -0,0 +1,49 @@
+create([
+ 'name' => 'OpenAI API Key',
+ 'api_key' => 'sk-test-value-that-is-long-enough',
+ 'is_active' => true,
+ ]);
+
+ $raw = $setting->getRawOriginal('api_key');
+
+ $this->assertNotSame('sk-test-value-that-is-long-enough', $raw);
+ $this->assertSame('sk-test-value-that-is-long-enough', $setting->api_key);
+ }
+
+ /**
+ * It resolves the latest active API key setting.
+ */
+ public function test_it_resolves_latest_active_api_key_setting(): void
+ {
+ ChatGptApiSetting::query()->create([
+ 'name' => 'Inactive',
+ 'api_key' => 'sk-inactive-value-that-is-long-enough',
+ 'is_active' => false,
+ ]);
+
+ $active = ChatGptApiSetting::query()->create([
+ 'name' => 'Active',
+ 'api_key' => 'sk-active-value-that-is-long-enough',
+ 'is_active' => true,
+ ]);
+
+ $this->assertTrue($active->is(ChatGptApiSetting::active()));
+ }
+}
diff --git a/Modules/ChatGPT/changelog.yaml b/Modules/ChatGPT/changelog.yaml
new file mode 100644
index 00000000..e6ef5636
--- /dev/null
+++ b/Modules/ChatGPT/changelog.yaml
@@ -0,0 +1,8 @@
+- version: 1.1.0
+ date: 2026-06-24
+ changes:
+ - Added encrypted database storage for OpenAI API keys.
+ - Added ChatGPT module settings UI to create, edit, activate, and delete stored API keys.
+ - Added chatgpt:delete-api-files command with dry-run support and no interactive confirmation.
+ - Added OpenAI files service for listing and deleting API files.
+ - Added feature tests for encrypted key storage and active key resolution.
diff --git a/Modules/ChatGPT/module.json b/Modules/ChatGPT/module.json
index 9b76c95d..f547c4b5 100644
--- a/Modules/ChatGPT/module.json
+++ b/Modules/ChatGPT/module.json
@@ -1,7 +1,7 @@
{
"name": "ChatGPT",
"alias": "chatgpt",
- "version": "1.0.0",
+ "version": "1.1.0",
"description": "",
"keywords": [],
"priority": 0,
diff --git a/Modules/ChatGPT/readme.md b/Modules/ChatGPT/readme.md
index e69de29b..fd961169 100644
--- a/Modules/ChatGPT/readme.md
+++ b/Modules/ChatGPT/readme.md
@@ -0,0 +1,28 @@
+# ChatGPT Module
+
+Provides encrypted OpenAI API key storage and an Artisan command for deleting all OpenAI API files visible to the active stored key.
+
+## Routes
+
+- `GET /chatgpt` - manage stored API keys.
+- `POST /chatgpt/settings` - create a stored API key.
+- `PUT /chatgpt/settings/{setting}` - update an API key setting.
+- `DELETE /chatgpt/settings/{setting}` - delete an API key setting.
+
+## Commands
+
+Preview files without deleting:
+
+```bash
+php artisan chatgpt:delete-api-files --dry-run
+```
+
+Delete all OpenAI API files without interactive confirmation:
+
+```bash
+php artisan chatgpt:delete-api-files
+```
+
+## Security
+
+API keys are encrypted at rest using Laravel's encrypted Eloquent cast and the application `APP_KEY`.
diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php
index 521e7582..c5922cfa 100644
--- a/Modules/Helpdesk/Config/config.php
+++ b/Modules/Helpdesk/Config/config.php
@@ -7,7 +7,16 @@ return [
'middleware' => ['web', 'auth'],
'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'),
'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240),
- 'allowed_mimes' => ['md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'],
+ 'allowed_extensions' => [
+ 'md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'log', 'zip',
+ 'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
+ 'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
+ 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
+ 'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config',
+ ],
+
+ // Backwards-compatible alias for older module code/tests that still read this key.
+ 'allowed_mimes' => [],
'default_status_slug' => 'new',
'closed_status_slug' => 'closed',
];
diff --git a/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php
index d963ef43..05ace4c5 100644
--- a/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php
+++ b/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php
@@ -22,12 +22,9 @@ final class TicketAttachmentStoreController extends Controller
{
Gate::authorize('update', $ticket);
- $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
- $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
-
$request->validate([
'attachments' => ['required', 'array', 'min:1'],
- 'attachments.*' => ['required', 'file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)],
+ 'attachments.*' => $this->attachmentFileRules(required: true),
]);
$user = Auth::user();
@@ -43,4 +40,36 @@ final class TicketAttachmentStoreController extends Controller
->route('helpdesk.tickets.show', $ticket)
->with('ticket-attachments-added', count($uploadedFiles) . ' supporting file' . (count($uploadedFiles) === 1 ? '' : 's') . ' uploaded.');
}
+
+ /**
+ * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
+ *
+ * @param bool $required Whether at least one uploaded file is required.
+ * @return array
+ */
+ private function attachmentFileRules(bool $required = false): array
+ {
+ return array_values(array_filter([
+ $required ? 'required' : null,
+ 'file',
+ 'max:' . (int) config('helpdesk.max_upload_kb', 10240),
+ 'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
+ ]));
+ }
+
+ /**
+ * Return lower-case Helpdesk attachment extensions.
+ *
+ * @return array
+ */
+ private function allowedAttachmentExtensions(): array
+ {
+ return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
+ ->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
+ ->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
+ ->unique()
+ ->values()
+ ->all();
+ }
+
}
diff --git a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php
index c3ba3d2b..4026c47a 100644
--- a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php
+++ b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php
@@ -29,9 +29,6 @@ final class TicketCommentStoreController extends Controller
{
Gate::authorize('comment', $ticket);
- $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
- $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
-
$validated = $request->validate([
'subject' => ['nullable', 'string', 'max:180'],
'commentBody' => ['nullable', 'string'],
@@ -43,7 +40,7 @@ final class TicketCommentStoreController extends Controller
],
'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')],
'attachments' => ['nullable', 'array'],
- 'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)],
+ 'attachments.*' => $this->attachmentFileRules(),
]);
$user = Auth::user();
@@ -168,4 +165,36 @@ final class TicketCommentStoreController extends Controller
|| Str::lower((string) ($company->slug ?? '')) === 'mpba-limited'
|| Str::contains(Str::lower((string) ($company->name ?? '')), 'mpba');
}
+
+ /**
+ * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
+ *
+ * @param bool $required Whether at least one uploaded file is required.
+ * @return array
+ */
+ private function attachmentFileRules(bool $required = false): array
+ {
+ return array_values(array_filter([
+ $required ? 'required' : null,
+ 'file',
+ 'max:' . (int) config('helpdesk.max_upload_kb', 10240),
+ 'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
+ ]));
+ }
+
+ /**
+ * Return lower-case Helpdesk attachment extensions.
+ *
+ * @return array
+ */
+ private function allowedAttachmentExtensions(): array
+ {
+ return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
+ ->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
+ ->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
+ ->unique()
+ ->values()
+ ->all();
+ }
+
}
diff --git a/Modules/Helpdesk/Http/Controllers/TicketStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketStoreController.php
index 22acdebe..6313bcbd 100644
--- a/Modules/Helpdesk/Http/Controllers/TicketStoreController.php
+++ b/Modules/Helpdesk/Http/Controllers/TicketStoreController.php
@@ -21,9 +21,6 @@ final class TicketStoreController extends Controller
*/
public function __invoke(Request $request, TicketService $tickets): RedirectResponse
{
- $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
- $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx']);
-
$validated = $request->validate([
'company_id' => ['nullable', 'integer'],
'subject' => ['required', 'string', 'max:200'],
@@ -35,7 +32,7 @@ final class TicketStoreController extends Controller
'team_id' => ['nullable', 'integer', Rule::exists('helpdesk_teams', 'id')],
'due_at' => ['nullable', 'date'],
'attachments' => ['nullable', 'array'],
- 'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)],
+ 'attachments.*' => $this->attachmentFileRules(),
]);
$user = Auth::user();
@@ -61,4 +58,36 @@ final class TicketStoreController extends Controller
->route('helpdesk.tickets.index')
->with('success', 'Ticket ' . $ticket->reference . ' has been created.');
}
+
+ /**
+ * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
+ *
+ * @param bool $required Whether at least one uploaded file is required.
+ * @return array
+ */
+ private function attachmentFileRules(bool $required = false): array
+ {
+ return array_values(array_filter([
+ $required ? 'required' : null,
+ 'file',
+ 'max:' . (int) config('helpdesk.max_upload_kb', 10240),
+ 'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
+ ]));
+ }
+
+ /**
+ * Return lower-case Helpdesk attachment extensions.
+ *
+ * @return array
+ */
+ private function allowedAttachmentExtensions(): array
+ {
+ return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
+ ->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
+ ->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
+ ->unique()
+ ->values()
+ ->all();
+ }
+
}
diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php
index 6256dbc3..3cbb635e 100644
--- a/Modules/Helpdesk/Livewire/TicketCreate.php
+++ b/Modules/Helpdesk/Livewire/TicketCreate.php
@@ -74,11 +74,7 @@ final class TicketCreate extends Component
'tag_ids' => ['array'],
'tag_ids.*' => ['integer', 'exists:helpdesk_tags,id'],
'attachments' => ['array'],
- 'attachments.*' => [
- 'file',
- 'max:' . config('helpdesk.max_upload_kb'),
- 'mimes:' . implode(',', config('helpdesk.allowed_mimes')),
- ],
+ 'attachments.*' => $this->attachmentFileRules(),
];
}
@@ -318,4 +314,37 @@ final class TicketCreate extends Component
{
return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default');
}
+
+ /**
+ * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
+ *
+ * Laravel validates source-code attachments by extension because browsers often report
+ * PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
+ *
+ * @return array
+ */
+ private function attachmentFileRules(): array
+ {
+ return [
+ 'file',
+ 'max:' . (int) config('helpdesk.max_upload_kb', 10240),
+ 'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
+ ];
+ }
+
+ /**
+ * Return lower-case Helpdesk attachment extensions.
+ *
+ * @return array
+ */
+ private function allowedAttachmentExtensions(): array
+ {
+ return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
+ ->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
+ ->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
+ ->unique()
+ ->values()
+ ->all();
+ }
+
}
diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php
index be75f718..8b0f7c58 100644
--- a/Modules/Helpdesk/Livewire/TicketEdit.php
+++ b/Modules/Helpdesk/Livewire/TicketEdit.php
@@ -109,7 +109,7 @@ final class TicketEdit extends Component
$this->validate();
$this->validate([
'attachments' => 'array',
- 'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb', 10240) . '|mimes:' . implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])),
+ 'attachments.*' => $this->attachmentFileRules(),
'tag_ids.*' => 'integer|exists:helpdesk_tags,id',
]);
@@ -317,4 +317,37 @@ final class TicketEdit extends Component
return $payload;
}
+
+ /**
+ * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
+ *
+ * Laravel validates source-code attachments by extension because browsers often report
+ * PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
+ *
+ * @return array
+ */
+ private function attachmentFileRules(): array
+ {
+ return [
+ 'file',
+ 'max:' . (int) config('helpdesk.max_upload_kb', 10240),
+ 'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
+ ];
+ }
+
+ /**
+ * Return lower-case Helpdesk attachment extensions.
+ *
+ * @return array
+ */
+ private function allowedAttachmentExtensions(): array
+ {
+ return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
+ ->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
+ ->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
+ ->unique()
+ ->values()
+ ->all();
+ }
+
}
diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php
index bd3428f9..2e05558b 100644
--- a/Modules/Helpdesk/Livewire/TicketShow.php
+++ b/Modules/Helpdesk/Livewire/TicketShow.php
@@ -92,7 +92,7 @@ final class TicketShow extends Component
$this->validate([
'commentBody' => ['required', 'string', 'min:2'],
- 'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb', 10240) . '|mimes:' . implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])),
+ 'attachments.*' => $this->attachmentFileRules(),
]);
$tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments);
@@ -299,12 +299,9 @@ final class TicketShow extends Component
{
$this->authorize('comment', $this->ticket);
- $maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
- $allowedMimes = implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']));
-
$this->validate([
'supportingFiles' => ['required', 'array', 'min:1'],
- 'supportingFiles.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . $allowedMimes],
+ 'supportingFiles.*' => $this->attachmentFileRules(),
]);
$user = Auth::user();
@@ -673,4 +670,37 @@ final class TicketShow extends Component
return $comment;
})->values();
}
+
+ /**
+ * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
+ *
+ * Laravel validates source-code attachments by extension because browsers often report
+ * PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
+ *
+ * @return array
+ */
+ private function attachmentFileRules(): array
+ {
+ return [
+ 'file',
+ 'max:' . (int) config('helpdesk.max_upload_kb', 10240),
+ 'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
+ ];
+ }
+
+ /**
+ * Return lower-case Helpdesk attachment extensions.
+ *
+ * @return array
+ */
+ private function allowedAttachmentExtensions(): array
+ {
+ return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
+ ->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
+ ->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
+ ->unique()
+ ->values()
+ ->all();
+ }
+
}
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php
index 32b2db16..85ebbfd4 100644
--- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php
+++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php
@@ -1,3 +1,10 @@
+@php
+ $helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
+ ->filter(fn ($extension) => is_string($extension) && trim($extension) !== '')
+ ->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.')))
+ ->unique()
+ ->implode(',');
+@endphp
@vite(['resources/css/app.css', 'resources/js/app.js'])
@include('helpdesk::partials.page-header', [
@@ -7,7 +14,24 @@
'Raise a new support request with priority, product/service, project, category, team and full details.',
])
-