new deployer command

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