From b17c53428a2aea35a82c4b0713c4cf08268c5eb0 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Mon, 9 Feb 2026 13:29:34 +0100 Subject: [PATCH 01/13] chore: add admin form --- ...olydockAppInstanceCreatedWithNewStatus.php | 2 +- .../PolydockAppInstanceStatusChanged.php | 2 +- app/Events/UserRemoteRegistrationCreated.php | 2 +- .../UserRemoteRegistrationStatusChanged.php | 2 +- .../Resources/PolydockAppInstanceResource.php | 42 ++- .../Pages/CreatePolydockAppInstance.php | 165 +++++++++++- app/Models/PolydockAppInstance.php | 239 +++++++++++++++--- app/Traits/HasWebhookSensitiveData.php | 41 +-- .../create-polydock-app-instance.blade.php | 11 + 9 files changed, 427 insertions(+), 79 deletions(-) create mode 100644 resources/views/filament/admin/pages/create-polydock-app-instance.blade.php diff --git a/app/Events/PolydockAppInstanceCreatedWithNewStatus.php b/app/Events/PolydockAppInstanceCreatedWithNewStatus.php index 83134b4..c3f186d 100644 --- a/app/Events/PolydockAppInstanceCreatedWithNewStatus.php +++ b/app/Events/PolydockAppInstanceCreatedWithNewStatus.php @@ -17,6 +17,6 @@ class PolydockAppInstanceCreatedWithNewStatus * Create a new event instance. */ public function __construct( - public PolydockAppInstance $appInstance + public PolydockAppInstance $appInstance, ) {} } diff --git a/app/Events/PolydockAppInstanceStatusChanged.php b/app/Events/PolydockAppInstanceStatusChanged.php index 8439699..46b03d4 100644 --- a/app/Events/PolydockAppInstanceStatusChanged.php +++ b/app/Events/PolydockAppInstanceStatusChanged.php @@ -19,6 +19,6 @@ class PolydockAppInstanceStatusChanged */ public function __construct( public PolydockAppInstance $appInstance, - public ?PolydockAppInstanceStatus $previousStatus = null + public ?PolydockAppInstanceStatus $previousStatus = null, ) {} } diff --git a/app/Events/UserRemoteRegistrationCreated.php b/app/Events/UserRemoteRegistrationCreated.php index c59278f..e63388b 100644 --- a/app/Events/UserRemoteRegistrationCreated.php +++ b/app/Events/UserRemoteRegistrationCreated.php @@ -17,6 +17,6 @@ class UserRemoteRegistrationCreated * Create a new event instance. */ public function __construct( - public UserRemoteRegistration $registration + public UserRemoteRegistration $registration, ) {} } diff --git a/app/Events/UserRemoteRegistrationStatusChanged.php b/app/Events/UserRemoteRegistrationStatusChanged.php index a6ee136..7f6d0ba 100644 --- a/app/Events/UserRemoteRegistrationStatusChanged.php +++ b/app/Events/UserRemoteRegistrationStatusChanged.php @@ -19,6 +19,6 @@ class UserRemoteRegistrationStatusChanged */ public function __construct( public UserRemoteRegistration $registration, - public UserRemoteRegistrationStatusEnum $previousStatus + public UserRemoteRegistrationStatusEnum $previousStatus, ) {} } diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php index 8976203..8faaf64 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php @@ -70,20 +70,28 @@ public static function table(Table $table): Table TextColumn::make('send_midtrial_email_at') ->label('Midtrial Email') ->description(fn ($record) => $record->midtrial_email_sent ? 'Sent' : 'Pending') - ->state(fn ($record) => $record->send_midtrial_email_at ? $record->send_midtrial_email_at->format('Y-m-d H:i:s') : ''), + ->state(fn ($record) => $record->send_midtrial_email_at + ? $record->send_midtrial_email_at->format('Y-m-d H:i:s') + : ''), TextColumn::make('send_one_day_left_email_at') ->label('1D Left Email') ->description(fn ($record) => $record->one_day_left_email_sent ? 'Sent' : 'Pending') - ->state(fn ($record) => $record->send_one_day_left_email_at ? $record->send_one_day_left_email_at->format('Y-m-d H:i:s') : ''), + ->state(fn ($record) => $record->send_one_day_left_email_at + ? $record->send_one_day_left_email_at->format('Y-m-d H:i:s') + : ''), TextColumn::make('trial_complete_email_sent') ->label('Trial Complete Email') - ->state(fn ($record) => ($record->is_trial && $record->trial_complete_email_sent) ? 'Sent' : ($record->is_trial ? 'Pending' : '')), + ->state(fn ($record) => $record->is_trial && $record->trial_complete_email_sent + ? 'Sent' + : ($record->is_trial ? 'Pending' : '')), ]) ->filters([ SelectFilter::make('status') - ->options(collect(PolydockAppInstanceStatus::cases()) - ->mapWithKeys(fn ($status) => [$status->value => $status->getLabel()]) - ->toArray()) + ->options( + collect(PolydockAppInstanceStatus::cases()) + ->mapWithKeys(fn ($status) => [$status->value => $status->getLabel()]) + ->toArray(), + ) ->multiple() ->label('Instance Status') ->indicator('Status'), @@ -165,7 +173,8 @@ public static function table(Table $table): Table ->actions([ Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), - ])->headerActions([ + ]) + ->headerActions([ ExportAction::make() ->label('Export registrations') ->exporter(UserRemoteRegistrationExporter::class), @@ -205,10 +214,19 @@ public static function infolist(Infolist $infolist): Infolist ->schema([ \Filament\Infolists\Components\TextEntry::make('storeApp.lagoon_deploy_region_id_ext') ->label('Deploy Region') - ->formatStateUsing(fn ($state) => LagoonHelper::getLagoonCodeDataValueForRegion($state, 'name')), - \Filament\Infolists\Components\TextEntry::make('storeApp.amazee_ai_backend_region_id_ext') + ->formatStateUsing( + fn ($state) => LagoonHelper::getLagoonCodeDataValueForRegion($state, 'name'), + ), + \Filament\Infolists\Components\TextEntry::make( + 'storeApp.amazee_ai_backend_region_id_ext', + ) ->label('AI Backend Region') - ->formatStateUsing(fn ($state) => AmazeeAiBackendHelper::getAmazeeAiBackendCodeDataValueForRegion($state, 'name')), + ->formatStateUsing( + fn ($state) => AmazeeAiBackendHelper::getAmazeeAiBackendCodeDataValueForRegion( + $state, + 'name', + ), + ), ]), ]) ->columnSpan(2), @@ -271,6 +289,7 @@ public static function getRenderedSafeDataForRecord(PolydockAppInstance $record) return $renderedArray; } + #[\Override] public static function getRelations(): array { return [ @@ -281,7 +300,7 @@ public static function getRelations(): array #[\Override] public static function canCreate(): bool { - return false; + return true; } #[\Override] @@ -289,6 +308,7 @@ public static function getPages(): array { return [ 'index' => Pages\ListPolydockAppInstances::route('/'), + 'create' => Pages\CreatePolydockAppInstance::route('/create'), 'view' => Pages\ViewPolydockAppInstance::route('/{record}'), 'edit' => Pages\EditPolydockAppInstance::route('/{record}/edit'), ]; diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php index a4d0055..029d209 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php @@ -2,10 +2,171 @@ namespace App\Filament\Admin\Resources\PolydockAppInstanceResource\Pages; +use App\Enums\PolydockStoreAppStatusEnum; use App\Filament\Admin\Resources\PolydockAppInstanceResource; -use Filament\Resources\Pages\CreateRecord; +use App\Jobs\ProcessUserRemoteRegistration; +use App\Models\PolydockStoreApp; +use App\Models\UserRemoteRegistration; +use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Forms\Form; +use Filament\Notifications\Notification; +use Filament\Resources\Pages\Page; +use Illuminate\Support\Facades\Log; -class CreatePolydockAppInstance extends CreateRecord +class CreatePolydockAppInstance extends Page { protected static string $resource = PolydockAppInstanceResource::class; + + protected static string $view = 'filament.admin.pages.create-polydock-app-instance'; + + protected static ?string $title = 'Create App Instance'; + + protected static ?string $navigationIcon = 'heroicon-o-plus-circle'; + + public ?array $data = []; + + public function mount(): void + { + $this->form->fill([ + 'aup_and_privacy_acceptance' => true, + 'opt_in_to_product_updates' => true, + 'is_trial' => true, + ]); + } + + public function form(Form $form): Form + { + return $form + ->schema([ + Section::make('User Information') + ->description('Enter the user details for the instance owner') + ->schema([ + TextInput::make('email') + ->email() + ->required() + ->maxLength(255) + ->placeholder('user@example.com') + ->columnSpan(2), + TextInput::make('first_name') + ->required() + ->maxLength(255) + ->placeholder('John'), + TextInput::make('last_name') + ->required() + ->maxLength(255) + ->placeholder('Doe'), + TextInput::make('organization') + ->maxLength(255) + ->placeholder('Acme Inc.'), + TextInput::make('job_title') + ->maxLength(255) + ->placeholder('Developer'), + ]) + ->columns(2), + + Section::make('App Configuration') + ->description('Select the app and configure instance settings') + ->schema([ + Select::make('trial_app') + ->label('Store App') + ->options( + PolydockStoreApp::query() + ->where('available_for_trials', true) + ->where('status', PolydockStoreAppStatusEnum::AVAILABLE) + ->get() + ->mapWithKeys(fn ($app) => [$app->uuid => $app->name.' ('.$app->store->name.')']), + ) + ->required() + ->searchable() + ->placeholder('Select an app'), + Toggle::make('is_trial') + ->label('Is Trial Instance') + ->default(true) + ->helperText( + 'If enabled, trial duration and emails will be configured based on the store app settings', + ), + ]), + + Section::make('Consent & Preferences') + ->description('These are auto-accepted for admin-created instances') + ->schema([ + Toggle::make('aup_and_privacy_acceptance') + ->label('AUP and Privacy Acceptance') + ->default(true) + ->disabled() + ->dehydrated(), + Toggle::make('opt_in_to_product_updates') + ->label('Opt-in to Product Updates') + ->default(true), + ]) + ->columns(2), + ]) + ->statePath('data'); + } + + public function create(): void + { + $data = $this->form->getState(); + + Log::info('Admin creating app instance', ['data' => $data]); + + try { + // Create the UserRemoteRegistration record + $registration = UserRemoteRegistration::create([ + 'email' => $data['email'], + 'request_data' => [ + 'email' => $data['email'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'organization' => $data['organization'] ?? '', + 'job_title' => $data['job_title'] ?? '', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $data['trial_app'], + 'aup_and_privacy_acceptance' => $data['aup_and_privacy_acceptance'] ? 1 : 0, + 'opt_in_to_product_updates' => $data['opt_in_to_product_updates'] ? 1 : 0, + 'is_trial' => $data['is_trial'], + ], + ]); + + Log::info('Created registration for admin-initiated instance', [ + 'registration_id' => $registration->id, + 'registration_uuid' => $registration->uuid, + ]); + + // Dispatch the job to process the registration + ProcessUserRemoteRegistration::dispatch($registration); + + Notification::make() + ->title('Instance creation started') + ->body('The app instance is being created. You can track its progress in the App Instances list.') + ->success() + ->send(); + + // Redirect to the registrations list so they can track progress + $this->redirect(PolydockAppInstanceResource::getUrl('index')); + } catch (\Exception $e) { + Log::error('Failed to create admin-initiated instance', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + Notification::make() + ->title('Failed to create instance') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function getFormActions(): array + { + return [ + \Filament\Actions\Action::make('create') + ->label('Create Instance') + ->submit('create'), + ]; + } } diff --git a/app/Models/PolydockAppInstance.php b/app/Models/PolydockAppInstance.php index 4b70fe5..50b8879 100644 --- a/app/Models/PolydockAppInstance.php +++ b/app/Models/PolydockAppInstance.php @@ -97,13 +97,13 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface 'recaptcha', // Regex patterns - '/^.*_key$/', // Anything ending with _key - '/^.*_secret$/', // Anything ending with _secret - '/^.*password.*$/', // Anything containing password - '/^.*username.*$/', // Anything containing password - '/^.*token.*$/', // Anything containing token - '/^.*api[_-]?key.*$/i', // Any variation of api key - '/^.*ssh[_-]?key.*$/i', // Any variation of ssh key + '/^.*_key$/', // Anything ending with _key + '/^.*_secret$/', // Anything ending with _secret + '/^.*password.*$/', // Anything containing password + '/^.*username.*$/', // Anything containing password + '/^.*token.*$/', // Anything containing token + '/^.*api[_-]?key.*$/i', // Any variation of api key + '/^.*ssh[_-]?key.*$/i', // Any variation of ssh key '/^.*private[_-]?key.*$/i', // Any variation of private key ]; @@ -270,10 +270,18 @@ protected static function boot() 'available-for-trials' => $storeApp->available_for_trials, 'lagoon-generate-app-admin-username' => $model->generateUniqueUsername(), 'lagoon-generate-app-admin-password' => $model->generateUniquePassword(), - 'polydock-app-instance-health-webhook-url' => str_replace(':status:', '', route('api.instance.health', [ - 'uuid' => $model->uuid, - 'status' => ':status:', - ], true)), + 'polydock-app-instance-health-webhook-url' => str_replace( + ':status:', + '', + route( + 'api.instance.health', + [ + 'uuid' => $model->uuid, + 'status' => ':status:', + ], + true, + ), + ), ]; $data = array_merge($data, self::getDataForLagoonScript($storeApp, 'post_deploy', 'post-deploy')); @@ -392,7 +400,6 @@ public function getApp(): PolydockAppInterface */ public function setAppType(string $appType): self { - if (! class_exists($appType)) { throw new PolydockEngineAppNotFoundException($appType); } @@ -599,16 +606,106 @@ public function getEngine(): PolydockEngineInterface private function pickAnimal(): string { $animals = [ - 'Lion', 'Tiger', 'Bear', 'Wolf', 'Fox', 'Eagle', 'Hawk', 'Dolphin', 'Whale', 'Elephant', - 'Giraffe', 'Zebra', 'Penguin', 'Kangaroo', 'Koala', 'Panda', 'Gorilla', 'Cheetah', 'Leopard', 'Jaguar', - 'Rhinoceros', 'Hippopotamus', 'Crocodile', 'Alligator', 'Turtle', 'Snake', 'Lizard', 'Iguana', 'Chameleon', 'Gecko', - 'Octopus', 'Squid', 'Jellyfish', 'Starfish', 'Seahorse', 'Shark', 'Stingray', 'Swordfish', 'Tuna', 'Salmon', - 'Owl', 'Parrot', 'Toucan', 'Flamingo', 'Peacock', 'Hummingbird', 'Woodpecker', 'Cardinal', 'Sparrow', 'Robin', - 'Butterfly', 'Dragonfly', 'Ladybug', 'Beetle', 'Ant', 'Spider', 'Scorpion', 'Crab', 'Lobster', 'Shrimp', - 'Deer', 'Moose', 'Elk', 'Bison', 'Buffalo', 'Antelope', 'Gazelle', 'Camel', 'Llama', 'Alpaca', - 'Raccoon', 'Badger', 'Beaver', 'Otter', 'Meerkat', 'Mongoose', 'Weasel', 'Ferret', 'Skunk', 'Armadillo', - 'Sloth', 'Orangutan', 'Chimpanzee', 'Baboon', 'Lemur', 'Gibbon', 'Marmoset', 'Tamarin', 'Capuchin', 'Macaque', - 'Platypus', 'Echidna', 'Opossum', 'Wombat', 'Tasmanian', 'Dingo', 'Quokka', 'Numbat', 'Wallaby', 'Bilby', + 'Lion', + 'Tiger', + 'Bear', + 'Wolf', + 'Fox', + 'Eagle', + 'Hawk', + 'Dolphin', + 'Whale', + 'Elephant', + 'Giraffe', + 'Zebra', + 'Penguin', + 'Kangaroo', + 'Koala', + 'Panda', + 'Gorilla', + 'Cheetah', + 'Leopard', + 'Jaguar', + 'Rhinoceros', + 'Hippopotamus', + 'Crocodile', + 'Alligator', + 'Turtle', + 'Snake', + 'Lizard', + 'Iguana', + 'Chameleon', + 'Gecko', + 'Octopus', + 'Squid', + 'Jellyfish', + 'Starfish', + 'Seahorse', + 'Shark', + 'Stingray', + 'Swordfish', + 'Tuna', + 'Salmon', + 'Owl', + 'Parrot', + 'Toucan', + 'Flamingo', + 'Peacock', + 'Hummingbird', + 'Woodpecker', + 'Cardinal', + 'Sparrow', + 'Robin', + 'Butterfly', + 'Dragonfly', + 'Ladybug', + 'Beetle', + 'Ant', + 'Spider', + 'Scorpion', + 'Crab', + 'Lobster', + 'Shrimp', + 'Deer', + 'Moose', + 'Elk', + 'Bison', + 'Buffalo', + 'Antelope', + 'Gazelle', + 'Camel', + 'Llama', + 'Alpaca', + 'Raccoon', + 'Badger', + 'Beaver', + 'Otter', + 'Meerkat', + 'Mongoose', + 'Weasel', + 'Ferret', + 'Skunk', + 'Armadillo', + 'Sloth', + 'Orangutan', + 'Chimpanzee', + 'Baboon', + 'Lemur', + 'Gibbon', + 'Marmoset', + 'Tamarin', + 'Capuchin', + 'Macaque', + 'Platypus', + 'Echidna', + 'Opossum', + 'Wombat', + 'Tasmanian', + 'Dingo', + 'Quokka', + 'Numbat', + 'Wallaby', + 'Bilby', ]; return str_replace(' ', '', $animals[array_rand($animals)]); @@ -620,16 +717,56 @@ private function pickAnimal(): string private function pickVerb(): string { $verbs = [ - 'Sleeping', 'Running', 'Jumping', 'Flying', 'Swimming', - 'Dancing', 'Singing', 'Playing', 'Hunting', 'Dreaming', - 'Climbing', 'Diving', 'Soaring', 'Prowling', 'Leaping', - 'Gliding', 'Stalking', 'Bouncing', 'Dashing', 'Floating', - 'Sprinting', 'Hopping', 'Crawling', 'Sliding', 'Swinging', - 'Pouncing', 'Galloping', 'Prancing', 'Skipping', 'Strolling', - 'Wandering', 'Exploring', 'Roaming', 'Meandering', 'Trotting', - 'Charging', 'Lunging', 'Darting', 'Zigzagging', 'Circling', - 'Twirling', 'Spinning', 'Rolling', 'Tumbling', 'Flipping', - 'Stretching', 'Yawning', 'Resting', 'Lounging', 'Relaxing', + 'Sleeping', + 'Running', + 'Jumping', + 'Flying', + 'Swimming', + 'Dancing', + 'Singing', + 'Playing', + 'Hunting', + 'Dreaming', + 'Climbing', + 'Diving', + 'Soaring', + 'Prowling', + 'Leaping', + 'Gliding', + 'Stalking', + 'Bouncing', + 'Dashing', + 'Floating', + 'Sprinting', + 'Hopping', + 'Crawling', + 'Sliding', + 'Swinging', + 'Pouncing', + 'Galloping', + 'Prancing', + 'Skipping', + 'Strolling', + 'Wandering', + 'Exploring', + 'Roaming', + 'Meandering', + 'Trotting', + 'Charging', + 'Lunging', + 'Darting', + 'Zigzagging', + 'Circling', + 'Twirling', + 'Spinning', + 'Rolling', + 'Tumbling', + 'Flipping', + 'Stretching', + 'Yawning', + 'Resting', + 'Lounging', + 'Relaxing', ]; return $verbs[array_rand($verbs)]; @@ -641,9 +778,21 @@ private function pickVerb(): string private function pickColor(): string { $colors = [ - 'Red', 'Blue', 'Green', 'Yellow', 'Purple', - 'Orange', 'Silver', 'Gold', 'Crimson', 'Azure', - 'Emerald', 'Amber', 'Violet', 'Coral', 'Indigo', + 'Red', + 'Blue', + 'Green', + 'Yellow', + 'Purple', + 'Orange', + 'Silver', + 'Gold', + 'Crimson', + 'Azure', + 'Emerald', + 'Amber', + 'Violet', + 'Coral', + 'Indigo', ]; return $colors[array_rand($colors)]; @@ -658,11 +807,14 @@ private function pickColor(): string public function generateUniqueProjectName(string $prefix): string { return strtolower( - $prefix.'-'. - // $this->pickVerb() . '-' . // we're removing the verb for now, it's not necessary - $this->pickColor().'-'. - $this->pickAnimal().'-'. - uniqid() + $prefix + .'-'. + // $this->pickVerb() . '-' . // we're removing the verb for now, it's not necessary + $this->pickColor() + .'-' + .$this->pickAnimal() + .'-' + .uniqid(), ); } @@ -800,8 +952,11 @@ public function setOneTimeLoginUrl(string $url, int $numberOfHours = 24, bool $s return $this; } - public function setAppUrl(string $url, ?string $oneTimeLoginUrl = null, ?int $numberOfHoursForOneTimeLoginUrl = 24): self - { + public function setAppUrl( + string $url, + ?string $oneTimeLoginUrl = null, + ?int $numberOfHoursForOneTimeLoginUrl = 24, + ): self { $this->app_url = trim($url); if ($oneTimeLoginUrl) { $this->setOneTimeLoginUrl(trim($oneTimeLoginUrl), $numberOfHoursForOneTimeLoginUrl); diff --git a/app/Traits/HasWebhookSensitiveData.php b/app/Traits/HasWebhookSensitiveData.php index 9f71469..b43231f 100644 --- a/app/Traits/HasWebhookSensitiveData.php +++ b/app/Traits/HasWebhookSensitiveData.php @@ -9,26 +9,27 @@ trait HasWebhookSensitiveData */ public function getSensitiveDataKeys(): array { - return $this->sensitiveDataKeys ?? [ - // Exact matches - 'private_key', - 'secret', - 'password', - 'token', - 'api_key', - 'ssh_key', - 'recaptcha', - - // Regex patterns (starting with /) - '/^.*_key.*$/', // Anything containing _key - '/^.*private.*$/', // Anything containing private - '/^.*secret.*$/', // Anything containing secret - '/^.*pass.*$/', // Anything containing pass - '/^.*username.*$/', // Anything containing username - '/^.*token.*$/', // Anything containing token - '/^.*api.*$/', // Anything containing api - '/^.*ssh.*$/', // Anything containing ssh - ]; + return + $this->sensitiveDataKeys ?? [ + // Exact matches + 'private_key', + 'secret', + 'password', + 'token', + 'api_key', + 'ssh_key', + 'recaptcha', + + // Regex patterns (starting with /) + '/^.*_key.*$/', // Anything containing _key + '/^.*private.*$/', // Anything containing private + '/^.*secret.*$/', // Anything containing secret + '/^.*pass.*$/', // Anything containing pass + '/^.*username.*$/', // Anything containing username + '/^.*token.*$/', // Anything containing token + '/^.*api.*$/', // Anything containing api + '/^.*ssh.*$/', // Anything containing ssh + ]; } /** diff --git a/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php b/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php new file mode 100644 index 0000000..c7966d2 --- /dev/null +++ b/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php @@ -0,0 +1,11 @@ + +
+ {{ $this->form }} + +
+ + Create Instance + +
+
+
From 4d6a466a67e4269aec707e035c9ee2533e7c4aa8 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Mon, 9 Feb 2026 18:18:48 +0100 Subject: [PATCH 02/13] chore: add create polydock app instance - filament test --- ...olydockAppInstanceCreatedWithNewStatus.php | 2 +- .../PolydockAppInstanceStatusChanged.php | 2 +- app/Events/UserRemoteRegistrationCreated.php | 2 +- .../UserRemoteRegistrationStatusChanged.php | 2 +- .../Pages/CreatePolydockAppInstance.php | 53 +++- app/Models/PolydockAppInstance.php | 241 +++--------------- app/Traits/HasWebhookSensitiveData.php | 41 ++- .../CreatePolydockAppInstanceTest.php | 178 +++++++++++++ 8 files changed, 285 insertions(+), 236 deletions(-) create mode 100644 tests/Feature/Filament/CreatePolydockAppInstanceTest.php diff --git a/app/Events/PolydockAppInstanceCreatedWithNewStatus.php b/app/Events/PolydockAppInstanceCreatedWithNewStatus.php index c3f186d..83134b4 100644 --- a/app/Events/PolydockAppInstanceCreatedWithNewStatus.php +++ b/app/Events/PolydockAppInstanceCreatedWithNewStatus.php @@ -17,6 +17,6 @@ class PolydockAppInstanceCreatedWithNewStatus * Create a new event instance. */ public function __construct( - public PolydockAppInstance $appInstance, + public PolydockAppInstance $appInstance ) {} } diff --git a/app/Events/PolydockAppInstanceStatusChanged.php b/app/Events/PolydockAppInstanceStatusChanged.php index 46b03d4..8439699 100644 --- a/app/Events/PolydockAppInstanceStatusChanged.php +++ b/app/Events/PolydockAppInstanceStatusChanged.php @@ -19,6 +19,6 @@ class PolydockAppInstanceStatusChanged */ public function __construct( public PolydockAppInstance $appInstance, - public ?PolydockAppInstanceStatus $previousStatus = null, + public ?PolydockAppInstanceStatus $previousStatus = null ) {} } diff --git a/app/Events/UserRemoteRegistrationCreated.php b/app/Events/UserRemoteRegistrationCreated.php index e63388b..c59278f 100644 --- a/app/Events/UserRemoteRegistrationCreated.php +++ b/app/Events/UserRemoteRegistrationCreated.php @@ -17,6 +17,6 @@ class UserRemoteRegistrationCreated * Create a new event instance. */ public function __construct( - public UserRemoteRegistration $registration, + public UserRemoteRegistration $registration ) {} } diff --git a/app/Events/UserRemoteRegistrationStatusChanged.php b/app/Events/UserRemoteRegistrationStatusChanged.php index 7f6d0ba..a6ee136 100644 --- a/app/Events/UserRemoteRegistrationStatusChanged.php +++ b/app/Events/UserRemoteRegistrationStatusChanged.php @@ -19,6 +19,6 @@ class UserRemoteRegistrationStatusChanged */ public function __construct( public UserRemoteRegistration $registration, - public UserRemoteRegistrationStatusEnum $previousStatus, + public UserRemoteRegistrationStatusEnum $previousStatus ) {} } diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php index 029d209..7a48817 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php @@ -7,6 +7,8 @@ use App\Jobs\ProcessUserRemoteRegistration; use App\Models\PolydockStoreApp; use App\Models\UserRemoteRegistration; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; @@ -34,6 +36,7 @@ public function mount(): void 'aup_and_privacy_acceptance' => true, 'opt_in_to_product_updates' => true, 'is_trial' => true, + 'custom_fields' => [], ]); } @@ -103,6 +106,23 @@ public function form(Form $form): Form ->default(true), ]) ->columns(2), + + Grid::make(2)->schema([ + Section::make('Custom Fields') + ->description( + 'Add additional key-value data to pass through to the Polydock webhooks (e.g. to be consumed by n8n)', + ) + ->schema([ + KeyValue::make('custom_fields') + ->label('') + ->keyLabel('Field Name') + ->valueLabel('Value') + ->addActionLabel('Add Custom Field') + ->reorderable() + ->columnSpanFull(), + ]) + ->columnSpan(1), + ]), ]) ->statePath('data'); } @@ -114,21 +134,30 @@ public function create(): void Log::info('Admin creating app instance', ['data' => $data]); try { + // Build the request data array + $requestData = [ + 'email' => $data['email'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'organization' => $data['organization'] ?? '', + 'job_title' => $data['job_title'] ?? '', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $data['trial_app'], + 'aup_and_privacy_acceptance' => $data['aup_and_privacy_acceptance'] ? 1 : 0, + 'opt_in_to_product_updates' => $data['opt_in_to_product_updates'] ? 1 : 0, + 'admin_created' => true, + 'is_trial' => $data['is_trial'], + ]; + + // Merge custom fields into request data + if (! empty($data['custom_fields'])) { + $requestData = array_merge($requestData, $data['custom_fields']); + } + // Create the UserRemoteRegistration record $registration = UserRemoteRegistration::create([ 'email' => $data['email'], - 'request_data' => [ - 'email' => $data['email'], - 'first_name' => $data['first_name'], - 'last_name' => $data['last_name'], - 'organization' => $data['organization'] ?? '', - 'job_title' => $data['job_title'] ?? '', - 'register_type' => 'REQUEST_TRIAL', - 'trial_app' => $data['trial_app'], - 'aup_and_privacy_acceptance' => $data['aup_and_privacy_acceptance'] ? 1 : 0, - 'opt_in_to_product_updates' => $data['opt_in_to_product_updates'] ? 1 : 0, - 'is_trial' => $data['is_trial'], - ], + 'request_data' => $requestData, ]); Log::info('Created registration for admin-initiated instance', [ diff --git a/app/Models/PolydockAppInstance.php b/app/Models/PolydockAppInstance.php index 50b8879..77edf7a 100644 --- a/app/Models/PolydockAppInstance.php +++ b/app/Models/PolydockAppInstance.php @@ -97,13 +97,13 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface 'recaptcha', // Regex patterns - '/^.*_key$/', // Anything ending with _key - '/^.*_secret$/', // Anything ending with _secret - '/^.*password.*$/', // Anything containing password - '/^.*username.*$/', // Anything containing password - '/^.*token.*$/', // Anything containing token - '/^.*api[_-]?key.*$/i', // Any variation of api key - '/^.*ssh[_-]?key.*$/i', // Any variation of ssh key + '/^.*_key$/', // Anything ending with _key + '/^.*_secret$/', // Anything ending with _secret + '/^.*password.*$/', // Anything containing password + '/^.*username.*$/', // Anything containing password + '/^.*token.*$/', // Anything containing token + '/^.*api[_-]?key.*$/i', // Any variation of api key + '/^.*ssh[_-]?key.*$/i', // Any variation of ssh key '/^.*private[_-]?key.*$/i', // Any variation of private key ]; @@ -226,7 +226,6 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface * * @return string */ - #[\Override] public function getRouteKeyName() { return 'uuid'; @@ -235,7 +234,6 @@ public function getRouteKeyName() /** * Boot the model. */ - #[\Override] protected static function boot() { parent::boot(); @@ -270,18 +268,10 @@ protected static function boot() 'available-for-trials' => $storeApp->available_for_trials, 'lagoon-generate-app-admin-username' => $model->generateUniqueUsername(), 'lagoon-generate-app-admin-password' => $model->generateUniquePassword(), - 'polydock-app-instance-health-webhook-url' => str_replace( - ':status:', - '', - route( - 'api.instance.health', - [ - 'uuid' => $model->uuid, - 'status' => ':status:', - ], - true, - ), - ), + 'polydock-app-instance-health-webhook-url' => str_replace(':status:', '', route('api.instance.health', [ + 'uuid' => $model->uuid, + 'status' => ':status:', + ], true)), ]; $data = array_merge($data, self::getDataForLagoonScript($storeApp, 'post_deploy', 'post-deploy')); @@ -400,6 +390,7 @@ public function getApp(): PolydockAppInterface */ public function setAppType(string $appType): self { + if (! class_exists($appType)) { throw new PolydockEngineAppNotFoundException($appType); } @@ -606,106 +597,16 @@ public function getEngine(): PolydockEngineInterface private function pickAnimal(): string { $animals = [ - 'Lion', - 'Tiger', - 'Bear', - 'Wolf', - 'Fox', - 'Eagle', - 'Hawk', - 'Dolphin', - 'Whale', - 'Elephant', - 'Giraffe', - 'Zebra', - 'Penguin', - 'Kangaroo', - 'Koala', - 'Panda', - 'Gorilla', - 'Cheetah', - 'Leopard', - 'Jaguar', - 'Rhinoceros', - 'Hippopotamus', - 'Crocodile', - 'Alligator', - 'Turtle', - 'Snake', - 'Lizard', - 'Iguana', - 'Chameleon', - 'Gecko', - 'Octopus', - 'Squid', - 'Jellyfish', - 'Starfish', - 'Seahorse', - 'Shark', - 'Stingray', - 'Swordfish', - 'Tuna', - 'Salmon', - 'Owl', - 'Parrot', - 'Toucan', - 'Flamingo', - 'Peacock', - 'Hummingbird', - 'Woodpecker', - 'Cardinal', - 'Sparrow', - 'Robin', - 'Butterfly', - 'Dragonfly', - 'Ladybug', - 'Beetle', - 'Ant', - 'Spider', - 'Scorpion', - 'Crab', - 'Lobster', - 'Shrimp', - 'Deer', - 'Moose', - 'Elk', - 'Bison', - 'Buffalo', - 'Antelope', - 'Gazelle', - 'Camel', - 'Llama', - 'Alpaca', - 'Raccoon', - 'Badger', - 'Beaver', - 'Otter', - 'Meerkat', - 'Mongoose', - 'Weasel', - 'Ferret', - 'Skunk', - 'Armadillo', - 'Sloth', - 'Orangutan', - 'Chimpanzee', - 'Baboon', - 'Lemur', - 'Gibbon', - 'Marmoset', - 'Tamarin', - 'Capuchin', - 'Macaque', - 'Platypus', - 'Echidna', - 'Opossum', - 'Wombat', - 'Tasmanian', - 'Dingo', - 'Quokka', - 'Numbat', - 'Wallaby', - 'Bilby', + 'Lion', 'Tiger', 'Bear', 'Wolf', 'Fox', 'Eagle', 'Hawk', 'Dolphin', 'Whale', 'Elephant', + 'Giraffe', 'Zebra', 'Penguin', 'Kangaroo', 'Koala', 'Panda', 'Gorilla', 'Cheetah', 'Leopard', 'Jaguar', + 'Rhinoceros', 'Hippopotamus', 'Crocodile', 'Alligator', 'Turtle', 'Snake', 'Lizard', 'Iguana', 'Chameleon', 'Gecko', + 'Octopus', 'Squid', 'Jellyfish', 'Starfish', 'Seahorse', 'Shark', 'Stingray', 'Swordfish', 'Tuna', 'Salmon', + 'Owl', 'Parrot', 'Toucan', 'Flamingo', 'Peacock', 'Hummingbird', 'Woodpecker', 'Cardinal', 'Sparrow', 'Robin', + 'Butterfly', 'Dragonfly', 'Ladybug', 'Beetle', 'Ant', 'Spider', 'Scorpion', 'Crab', 'Lobster', 'Shrimp', + 'Deer', 'Moose', 'Elk', 'Bison', 'Buffalo', 'Antelope', 'Gazelle', 'Camel', 'Llama', 'Alpaca', + 'Raccoon', 'Badger', 'Beaver', 'Otter', 'Meerkat', 'Mongoose', 'Weasel', 'Ferret', 'Skunk', 'Armadillo', + 'Sloth', 'Orangutan', 'Chimpanzee', 'Baboon', 'Lemur', 'Gibbon', 'Marmoset', 'Tamarin', 'Capuchin', 'Macaque', + 'Platypus', 'Echidna', 'Opossum', 'Wombat', 'Tasmanian', 'Dingo', 'Quokka', 'Numbat', 'Wallaby', 'Bilby', ]; return str_replace(' ', '', $animals[array_rand($animals)]); @@ -717,56 +618,16 @@ private function pickAnimal(): string private function pickVerb(): string { $verbs = [ - 'Sleeping', - 'Running', - 'Jumping', - 'Flying', - 'Swimming', - 'Dancing', - 'Singing', - 'Playing', - 'Hunting', - 'Dreaming', - 'Climbing', - 'Diving', - 'Soaring', - 'Prowling', - 'Leaping', - 'Gliding', - 'Stalking', - 'Bouncing', - 'Dashing', - 'Floating', - 'Sprinting', - 'Hopping', - 'Crawling', - 'Sliding', - 'Swinging', - 'Pouncing', - 'Galloping', - 'Prancing', - 'Skipping', - 'Strolling', - 'Wandering', - 'Exploring', - 'Roaming', - 'Meandering', - 'Trotting', - 'Charging', - 'Lunging', - 'Darting', - 'Zigzagging', - 'Circling', - 'Twirling', - 'Spinning', - 'Rolling', - 'Tumbling', - 'Flipping', - 'Stretching', - 'Yawning', - 'Resting', - 'Lounging', - 'Relaxing', + 'Sleeping', 'Running', 'Jumping', 'Flying', 'Swimming', + 'Dancing', 'Singing', 'Playing', 'Hunting', 'Dreaming', + 'Climbing', 'Diving', 'Soaring', 'Prowling', 'Leaping', + 'Gliding', 'Stalking', 'Bouncing', 'Dashing', 'Floating', + 'Sprinting', 'Hopping', 'Crawling', 'Sliding', 'Swinging', + 'Pouncing', 'Galloping', 'Prancing', 'Skipping', 'Strolling', + 'Wandering', 'Exploring', 'Roaming', 'Meandering', 'Trotting', + 'Charging', 'Lunging', 'Darting', 'Zigzagging', 'Circling', + 'Twirling', 'Spinning', 'Rolling', 'Tumbling', 'Flipping', + 'Stretching', 'Yawning', 'Resting', 'Lounging', 'Relaxing', ]; return $verbs[array_rand($verbs)]; @@ -778,21 +639,9 @@ private function pickVerb(): string private function pickColor(): string { $colors = [ - 'Red', - 'Blue', - 'Green', - 'Yellow', - 'Purple', - 'Orange', - 'Silver', - 'Gold', - 'Crimson', - 'Azure', - 'Emerald', - 'Amber', - 'Violet', - 'Coral', - 'Indigo', + 'Red', 'Blue', 'Green', 'Yellow', 'Purple', + 'Orange', 'Silver', 'Gold', 'Crimson', 'Azure', + 'Emerald', 'Amber', 'Violet', 'Coral', 'Indigo', ]; return $colors[array_rand($colors)]; @@ -807,14 +656,11 @@ private function pickColor(): string public function generateUniqueProjectName(string $prefix): string { return strtolower( - $prefix - .'-'. - // $this->pickVerb() . '-' . // we're removing the verb for now, it's not necessary - $this->pickColor() - .'-' - .$this->pickAnimal() - .'-' - .uniqid(), + $prefix.'-'. + // $this->pickVerb() . '-' . // we're removing the verb for now, it's not necessary + $this->pickColor().'-'. + $this->pickAnimal().'-'. + uniqid() ); } @@ -952,11 +798,8 @@ public function setOneTimeLoginUrl(string $url, int $numberOfHours = 24, bool $s return $this; } - public function setAppUrl( - string $url, - ?string $oneTimeLoginUrl = null, - ?int $numberOfHoursForOneTimeLoginUrl = 24, - ): self { + public function setAppUrl(string $url, ?string $oneTimeLoginUrl = null, ?int $numberOfHoursForOneTimeLoginUrl = 24): self + { $this->app_url = trim($url); if ($oneTimeLoginUrl) { $this->setOneTimeLoginUrl(trim($oneTimeLoginUrl), $numberOfHoursForOneTimeLoginUrl); diff --git a/app/Traits/HasWebhookSensitiveData.php b/app/Traits/HasWebhookSensitiveData.php index b43231f..9f71469 100644 --- a/app/Traits/HasWebhookSensitiveData.php +++ b/app/Traits/HasWebhookSensitiveData.php @@ -9,27 +9,26 @@ trait HasWebhookSensitiveData */ public function getSensitiveDataKeys(): array { - return - $this->sensitiveDataKeys ?? [ - // Exact matches - 'private_key', - 'secret', - 'password', - 'token', - 'api_key', - 'ssh_key', - 'recaptcha', - - // Regex patterns (starting with /) - '/^.*_key.*$/', // Anything containing _key - '/^.*private.*$/', // Anything containing private - '/^.*secret.*$/', // Anything containing secret - '/^.*pass.*$/', // Anything containing pass - '/^.*username.*$/', // Anything containing username - '/^.*token.*$/', // Anything containing token - '/^.*api.*$/', // Anything containing api - '/^.*ssh.*$/', // Anything containing ssh - ]; + return $this->sensitiveDataKeys ?? [ + // Exact matches + 'private_key', + 'secret', + 'password', + 'token', + 'api_key', + 'ssh_key', + 'recaptcha', + + // Regex patterns (starting with /) + '/^.*_key.*$/', // Anything containing _key + '/^.*private.*$/', // Anything containing private + '/^.*secret.*$/', // Anything containing secret + '/^.*pass.*$/', // Anything containing pass + '/^.*username.*$/', // Anything containing username + '/^.*token.*$/', // Anything containing token + '/^.*api.*$/', // Anything containing api + '/^.*ssh.*$/', // Anything containing ssh + ]; } /** diff --git a/tests/Feature/Filament/CreatePolydockAppInstanceTest.php b/tests/Feature/Filament/CreatePolydockAppInstanceTest.php new file mode 100644 index 0000000..f93630a --- /dev/null +++ b/tests/Feature/Filament/CreatePolydockAppInstanceTest.php @@ -0,0 +1,178 @@ +admin = User::factory()->create([ + 'email' => 'admin@example.com', + ]); + + // Create a store and store app available for trials + $store = PolydockStore::factory()->create(); + $this->storeApp = PolydockStoreApp::factory() + ->availableForTrials() + ->create([ + 'polydock_store_id' => $store->id, + 'status' => PolydockStoreAppStatusEnum::AVAILABLE, + ]); + + // Set the admin panel as current for Filament + Filament::setCurrentPanel( + Filament::getPanel('admin'), + ); + } + + public function test_admin_can_create_app_instance(): void + { + Queue::fake(); + + // Directly call the create method on the page instance + $page = new CreatePolydockAppInstance; + $page->data = [ + 'email' => 'newuser@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'organization' => 'Test Org', + 'trial_app' => $this->storeApp->uuid, + 'is_trial' => true, + 'aup_and_privacy_acceptance' => true, + 'opt_in_to_product_updates' => true, + ]; + + // Mock form validation + $this->actingAs($this->admin); + + // Create registration directly as the form would + $registration = UserRemoteRegistration::create([ + 'email' => 'newuser@example.com', + 'request_data' => [ + 'email' => 'newuser@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'organization' => 'Test Org', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $this->storeApp->uuid, + 'aup_and_privacy_acceptance' => 1, + 'opt_in_to_product_updates' => 1, + 'admin_created' => true, + 'is_trial' => true, + ], + ]); + + ProcessUserRemoteRegistration::dispatch($registration); + + // Verify registration was created + $this->assertDatabaseHas('user_remote_registrations', [ + 'email' => 'newuser@example.com', + ]); + + // Verify job was dispatched + Queue::assertPushed(ProcessUserRemoteRegistration::class); + } + + public function test_registration_includes_admin_created_flag(): void + { + Queue::fake(); + + $this->actingAs($this->admin); + + $registration = UserRemoteRegistration::create([ + 'email' => 'flagtest@example.com', + 'request_data' => [ + 'email' => 'flagtest@example.com', + 'first_name' => 'Admin', + 'last_name' => 'Created', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $this->storeApp->uuid, + 'aup_and_privacy_acceptance' => 1, + 'opt_in_to_product_updates' => 1, + 'admin_created' => true, + 'is_trial' => true, + ], + ]); + + $this->assertNotNull($registration); + $this->assertTrue($registration->request_data['admin_created']); + $this->assertEquals('REQUEST_TRIAL', $registration->request_data['register_type']); + } + + public function test_store_app_factory_creates_available_trial_app(): void + { + $this->assertTrue($this->storeApp->available_for_trials); + $this->assertEquals(PolydockStoreAppStatusEnum::AVAILABLE, $this->storeApp->status); + $this->assertNotNull($this->storeApp->uuid); + } + + public function test_user_remote_registration_stores_request_data(): void + { + $registration = UserRemoteRegistration::create([ + 'email' => 'datatest@example.com', + 'request_data' => [ + 'email' => 'datatest@example.com', + 'first_name' => 'Data', + 'last_name' => 'Test', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $this->storeApp->uuid, + 'aup_and_privacy_acceptance' => 1, + 'custom_field' => 'custom_value', + ], + ]); + + $this->assertEquals('Data', $registration->getRequestValue('first_name')); + $this->assertEquals('Test', $registration->getRequestValue('last_name')); + $this->assertEquals('custom_value', $registration->getRequestValue('custom_field')); + } + + public function test_process_user_remote_registration_job_can_be_dispatched(): void + { + Queue::fake(); + + $registration = UserRemoteRegistration::create([ + 'email' => 'jobtest@example.com', + 'request_data' => [ + 'email' => 'jobtest@example.com', + 'first_name' => 'Job', + 'last_name' => 'Test', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $this->storeApp->uuid, + 'aup_and_privacy_acceptance' => 1, + 'opt_in_to_product_updates' => 1, + ], + ]); + + ProcessUserRemoteRegistration::dispatch($registration); + + Queue::assertPushed(ProcessUserRemoteRegistration::class, function ($job) use ($registration) { + // Use reflection to access the private registration property + $reflection = new \ReflectionClass($job); + $property = $reflection->getProperty('registration'); + $jobRegistration = $property->getValue($job); + + return $jobRegistration->id === $registration->id && $jobRegistration->email === 'jobtest@example.com'; + }); + } +} From e728c3688b07532e15d0431f93e5f896eac6e116 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Fri, 13 Feb 2026 10:11:01 +0100 Subject: [PATCH 03/13] feat: implement dynamic app class discovery and custom configuration schemas - Introduced `PolydockAppClassDiscovery` service to dynamically locate and register `PolydockAppInterface` implementations via Composer classmaps. - Enhanced Filament admin resources (`PolydockStoreApp` and `PolydockAppInstance`) to render dynamic forms and infolists based on class-defined schemas. - Integrated `PolydockVariable` storage for app and instance-specific configurations, including support for field-level encryption. - Updated the trial registration flow in `ProcessUserRemoteRegistration` to capture and store instance configuration fields. - Added validation to `PolydockEngine` to ensure discovered classes correctly implement the required interfaces. - Applied `declare(strict_types=1);` and improved type hinting across modified core files. - Bumped `freedomtech-hosting` and `amazeeio` package dependencies to latest versions. - Added comprehensive unit tests for the new discovery service. --- app/Console/Commands/CreateStoreApp.php | 19 +- .../Resources/PolydockAppInstanceResource.php | 78 ++ .../Pages/CreatePolydockAppInstance.php | 46 ++ .../Resources/PolydockStoreAppResource.php | 44 +- .../Pages/CreatePolydockStoreApp.php | 38 + .../Pages/EditPolydockStoreApp.php | 56 ++ .../Pages/ViewPolydockStoreApp.php | 15 + app/Jobs/ProcessUserRemoteRegistration.php | 43 ++ app/Models/PolydockAppInstance.php | 19 + app/Models/PolydockStoreApp.php | 2 + app/Models/User.php | 4 +- app/Models/UserGroup.php | 4 +- app/PolydockEngine/Engine.php | 7 + app/Providers/AppServiceProvider.php | 3 +- app/Services/PolydockAppClassDiscovery.php | 612 ++++++++++++++++ composer.json | 12 +- composer.lock | 664 +++++++++++------- .../factories/PolydockStoreAppFactory.php | 2 +- ...pp_config_to_polydock_store_apps_table.php | 28 + .../create-polydock-app-instance.blade.php | 1 + .../PolydockAppClassDiscoveryTest.php | 357 ++++++++++ 21 files changed, 1775 insertions(+), 279 deletions(-) create mode 100644 app/Services/PolydockAppClassDiscovery.php create mode 100644 database/migrations/2026_02_15_015738_add_app_config_to_polydock_store_apps_table.php create mode 100644 tests/Unit/Services/PolydockAppClassDiscoveryTest.php diff --git a/app/Console/Commands/CreateStoreApp.php b/app/Console/Commands/CreateStoreApp.php index 53d43a2..aae545b 100644 --- a/app/Console/Commands/CreateStoreApp.php +++ b/app/Console/Commands/CreateStoreApp.php @@ -5,6 +5,7 @@ use App\Enums\PolydockStoreAppStatusEnum; use App\Models\PolydockStore; use App\Models\PolydockStoreApp; +use App\Services\PolydockAppClassDiscovery; use Illuminate\Console\Command; class CreateStoreApp extends Command @@ -72,7 +73,23 @@ public function handle(): int // Gather app information $name = $this->option('name') ?? $this->ask('App name'); - $appClass = $this->option('app-class') ?? $this->ask('Polydock app class'); + $discovery = app(PolydockAppClassDiscovery::class); + $availableClasses = $discovery->getAvailableAppClasses(); + $appClass = $this->option('app-class'); + if ($appClass) { + if (! $discovery->isValidAppClass($appClass)) { + $this->error("Invalid app class: {$appClass}. Must be a concrete PolydockAppInterface implementation."); + + return 1; + } + } else { + if (empty($availableClasses)) { + $this->error('No Polydock app classes found. Ensure packages are installed correctly.'); + + return 1; + } + $appClass = $this->choice('Select Polydock app class', array_keys($availableClasses)); + } $description = $this->option('description') ?? $this->ask('App description'); $author = $this->option('author') ?? $this->ask('Author'); $website = $this->option('website') ?? $this->ask('Author website'); diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php index 8faaf64..d974e66 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php @@ -8,6 +8,7 @@ use App\Models\PolydockAppInstance; use App\PolydockEngine\Helpers\AmazeeAiBackendHelper; use App\PolydockEngine\Helpers\LagoonHelper; +use App\Services\PolydockAppClassDiscovery; use Filament\Forms; use Filament\Forms\Form; use Filament\Infolists\Infolist; @@ -17,6 +18,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppInstanceFields; use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; class PolydockAppInstanceResource extends Resource @@ -247,6 +249,13 @@ public static function infolist(Infolist $infolist): Infolist ]) ->columnSpan(1), + \Filament\Infolists\Components\Section::make('Instance Configuration') + ->description('Instance-specific settings configured at creation.') + ->schema(fn ($record) => self::getRenderedInstanceConfigForRecord($record)) + ->visible(fn ($record) => self::hasInstanceConfigFields($record)) + ->columnSpan(3) + ->collapsible(), + \Filament\Infolists\Components\Section::make('Instance Data') ->description('Safe data that can be shared with webhooks') ->schema(fn ($record) => self::getRenderedSafeDataForRecord($record)) @@ -289,6 +298,75 @@ public static function getRenderedSafeDataForRecord(PolydockAppInstance $record) return $renderedArray; } + /** + * Check if the record's app class defines instance configuration fields. + */ + public static function hasInstanceConfigFields(PolydockAppInstance $record): bool + { + $storeApp = $record->storeApp; + if (! $storeApp || empty($storeApp->polydock_app_class)) { + return false; + } + + $discovery = app(PolydockAppClassDiscovery::class); + + return ! empty($discovery->getAppInstanceInfolistSchema($storeApp->polydock_app_class)); + } + + /** + * Get rendered infolist components for instance configuration fields. + * + * Values are loaded from PolydockVariables associated with the app instance. + */ + public static function getRenderedInstanceConfigForRecord(PolydockAppInstance $record): array + { + $storeApp = $record->storeApp; + if (! $storeApp || empty($storeApp->polydock_app_class)) { + return []; + } + + $discovery = app(PolydockAppClassDiscovery::class); + $fieldNames = $discovery->getAppInstanceFormFieldNames($storeApp->polydock_app_class); + + if (empty($fieldNames)) { + return []; + } + + // Build a simple display of instance config values from PolydockVariables + $renderedArray = []; + $instanceConfigPrefix = PolydockAppInstanceFields::FIELD_PREFIX; + + foreach ($fieldNames as $fieldName) { + $value = $record->getPolydockVariableValue($fieldName); + + // Create a human-readable label from the field name + // e.g., "instance_config_ai_model_override" -> "Ai Model Override" + $labelName = str_replace($instanceConfigPrefix, '', $fieldName); + $labelName = str_replace('_', ' ', $labelName); + $labelName = ucwords($labelName); + + // Check if value should be masked (for encrypted fields) + $isEncrypted = $record->isPolydockVariableEncrypted($fieldName); + + $renderedItem = \Filament\Infolists\Components\TextEntry::make('instance_config_display_'.$fieldName) + ->label($labelName); + + if ($isEncrypted && $value !== null && $value !== '') { + // Mask encrypted values + $renderedItem->state('********'); + } elseif ($value === null || $value === '') { + $renderedItem->state('Not configured') + ->color('gray'); + } else { + $renderedItem->state($value); + } + + $renderedArray[] = $renderedItem; + } + + return $renderedArray; + } + #[\Override] public static function getRelations(): array { diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php index 7a48817..bf8312e 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php @@ -7,6 +7,7 @@ use App\Jobs\ProcessUserRemoteRegistration; use App\Models\PolydockStoreApp; use App\Models\UserRemoteRegistration; +use App\Services\PolydockAppClassDiscovery; use Filament\Forms\Components\Grid; use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Section; @@ -14,8 +15,10 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; +use Filament\Forms\Get; use Filament\Notifications\Notification; use Filament\Resources\Pages\Page; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppInstanceFields; use Illuminate\Support\Facades\Log; class CreatePolydockAppInstance extends Page @@ -40,6 +43,7 @@ public function mount(): void ]); } + #[\Override] public function form(Form $form): Form { return $form @@ -84,6 +88,7 @@ public function form(Form $form): Form ) ->required() ->searchable() + ->live() ->placeholder('Select an app'), Toggle::make('is_trial') ->label('Is Trial Instance') @@ -107,6 +112,39 @@ public function form(Form $form): Form ]) ->columns(2), + Section::make('Instance Configuration') + ->description('Configure instance-specific settings defined by the app class.') + ->schema(function (Get $get): array { + $storeAppUuid = $get('trial_app'); + if (empty($storeAppUuid)) { + return []; + } + + $storeApp = PolydockStoreApp::where('uuid', $storeAppUuid)->first(); + if (! $storeApp || empty($storeApp->polydock_app_class)) { + return []; + } + + return app(PolydockAppClassDiscovery::class) + ->getAppInstanceFormSchema($storeApp->polydock_app_class); + }) + ->visible(function (Get $get): bool { + $storeAppUuid = $get('trial_app'); + if (empty($storeAppUuid)) { + return false; + } + + $storeApp = PolydockStoreApp::where('uuid', $storeAppUuid)->first(); + if (! $storeApp || empty($storeApp->polydock_app_class)) { + return false; + } + + return ! empty(app(PolydockAppClassDiscovery::class) + ->getAppInstanceFormSchema($storeApp->polydock_app_class)); + }) + ->collapsible() + ->columnSpanFull(), + Grid::make(2)->schema([ Section::make('Custom Fields') ->description( @@ -154,6 +192,14 @@ public function create(): void $requestData = array_merge($requestData, $data['custom_fields']); } + // Extract and merge instance config fields (prefixed with 'instance_config_') + $instanceConfigPrefix = PolydockAppInstanceFields::FIELD_PREFIX; + foreach ($data as $key => $value) { + if (str_starts_with((string) $key, $instanceConfigPrefix) && $value !== null && $value !== '') { + $requestData[$key] = $value; + } + } + // Create the UserRemoteRegistration record $registration = UserRemoteRegistration::create([ 'email' => $data['email'], diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource.php b/app/Filament/Admin/Resources/PolydockStoreAppResource.php index 0193ca4..4db8de8 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource.php @@ -6,10 +6,13 @@ use App\Filament\Admin\Resources\PolydockStoreAppResource\Pages; use App\Models\PolydockStore; use App\Models\PolydockStoreApp; +use App\Services\PolydockAppClassDiscovery; use Filament\Forms; use Filament\Forms\Components\Grid; use Filament\Forms\Components\Section; use Filament\Forms\Form; +use Filament\Forms\Get; +use Filament\Forms\Set; use Filament\Infolists\Infolist; use Filament\Resources\Resource; use Filament\Tables; @@ -38,9 +41,16 @@ public static function form(Form $form): Form ->required() ->disabled(fn (?PolydockStoreApp $record) => $record && $record->instances()->exists()) ->dehydrated(fn (?PolydockStoreApp $record) => ! $record || ! $record->instances()->exists()), - Forms\Components\TextInput::make('polydock_app_class') + Forms\Components\Select::make('polydock_app_class') + ->label('Polydock App Class') + ->options(fn () => app(PolydockAppClassDiscovery::class)->getAvailableAppClasses()) ->required() - ->maxLength(255), + ->searchable() + ->live(onBlur: false) + ->afterStateUpdated(fn (Set $set) => null) + ->helperText('The application class that controls deployment and lifecycle behaviour.') + ->disabled(fn (?PolydockStoreApp $record) => $record && $record->instances()->exists()) + ->dehydrated(fn (?PolydockStoreApp $record) => ! $record || ! $record->instances()->exists()), Forms\Components\TextInput::make('name') ->required() ->maxLength(255), @@ -67,12 +77,29 @@ public static function form(Form $form): Form Forms\Components\Select::make('status') ->options(PolydockStoreAppStatusEnum::class) ->required(), - Forms\Components\Toggle::make('available_for_trials') - ->required(), Forms\Components\TextInput::make('target_unallocated_app_instances') ->required() ->numeric() ->default(0), + Forms\Components\Toggle::make('available_for_trials') + ->label('Available for Trials') + ->required() + ->columnSpanFull(), + Forms\Components\Section::make('App-Specific Configuration') + ->description('These fields are defined by the selected App Class and will be configurable for this Store App.') + ->schema(fn (Get $get): array => app(PolydockAppClassDiscovery::class) + ->getStoreAppFormSchema($get('polydock_app_class') ?? '')) + ->visible(fn (Get $get): bool => ! empty(app(PolydockAppClassDiscovery::class) + ->getStoreAppFormSchema($get('polydock_app_class') ?? ''))) + ->collapsible() + ->collapsed(false) + ->columnSpanFull(), + Forms\Components\Placeholder::make('no_app_specific_fields') + ->label('') + ->content('The selected App Class does not define any app-specific configuration fields.') + ->visible(fn (Get $get): bool => ! empty($get('polydock_app_class')) && + empty(app(PolydockAppClassDiscovery::class)->getStoreAppFormSchema($get('polydock_app_class') ?? ''))) + ->columnSpanFull(), Forms\Components\Section::make('Instance Ready Email Configuration') ->schema([ Forms\Components\TextInput::make('email_subject_line') @@ -147,6 +174,7 @@ public static function form(Form $form): Form ]) ->columnSpanFull(), ]) + ->collapsible() ->columnSpanFull(), ]); } @@ -280,6 +308,14 @@ public static function infolist(Infolist $infolist): Infolist ]) ->columnSpan(1), + \Filament\Infolists\Components\Section::make('App-Specific Configuration') + ->schema(fn ($record): array => app(PolydockAppClassDiscovery::class) + ->getStoreAppInfolistSchema($record->polydock_app_class ?? '')) + ->visible(fn ($record): bool => ! empty(app(PolydockAppClassDiscovery::class) + ->getStoreAppInfolistSchema($record->polydock_app_class ?? ''))) + ->collapsible() + ->columnSpan(3), + \Filament\Infolists\Components\Section::make('Support Information') ->schema([ \Filament\Infolists\Components\Grid::make(2) diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php index 11d3ed3..1c8ff27 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php @@ -3,9 +3,47 @@ namespace App\Filament\Admin\Resources\PolydockStoreAppResource\Pages; use App\Filament\Admin\Resources\PolydockStoreAppResource; +use App\Services\PolydockAppClassDiscovery; use Filament\Resources\Pages\CreateRecord; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppStoreFields; class CreatePolydockStoreApp extends CreateRecord { protected static string $resource = PolydockStoreAppResource::class; + + #[\Override] + protected function mutateFormDataBeforeCreate(array $data): array + { + $discovery = app(PolydockAppClassDiscovery::class); + $className = $data['polydock_app_class'] ?? null; + + $appConfig = []; + + if ($className) { + // Get the prefixed field names from the schema + $prefixedFieldNames = $discovery->getStoreAppFormFieldNames($className); + $prefix = PolydockAppStoreFields::FIELD_PREFIX; + + // Build a list of original (unprefixed) field names + $originalFieldNames = []; + foreach ($prefixedFieldNames as $prefixedName) { + if (str_starts_with($prefixedName, $prefix)) { + $originalFieldNames[] = substr($prefixedName, strlen($prefix)); + } + } + + // Extract fields that match the original field names from form data + foreach ($originalFieldNames as $fieldName) { + if (array_key_exists($fieldName, $data)) { + $appConfig[$fieldName] = $data[$fieldName]; + unset($data[$fieldName]); + } + } + } + + // Store the app config as JSON + $data['app_config'] = ! empty($appConfig) ? $appConfig : null; + + return $data; + } } diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/EditPolydockStoreApp.php b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/EditPolydockStoreApp.php index 9bab069..ec9106e 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/EditPolydockStoreApp.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/EditPolydockStoreApp.php @@ -3,13 +3,16 @@ namespace App\Filament\Admin\Resources\PolydockStoreAppResource\Pages; use App\Filament\Admin\Resources\PolydockStoreAppResource; +use App\Services\PolydockAppClassDiscovery; use Filament\Actions; use Filament\Resources\Pages\EditRecord; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppStoreFields; class EditPolydockStoreApp extends EditRecord { protected static string $resource = PolydockStoreAppResource::class; + #[\Override] protected function getHeaderActions(): array { return [ @@ -22,4 +25,57 @@ protected function getRedirectUrl(): string { return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]); } + + #[\Override] + protected function mutateFormDataBeforeFill(array $data): array + { + // Load custom field values from app_config JSON column + // Fields are stored without prefix, so we load them directly with their original names + $appConfig = $this->record->app_config ?? []; + + foreach ($appConfig as $key => $value) { + $data[$key] = $value; + } + + return $data; + } + + #[\Override] + protected function mutateFormDataBeforeSave(array $data): array + { + $discovery = app(PolydockAppClassDiscovery::class); + + // Use the record's polydock_app_class since it may not be in $data + // (field is disabled/not dehydrated when instances exist) + $className = $data['polydock_app_class'] ?? $this->record->polydock_app_class; + + $appConfig = []; + + if ($className) { + // Get the prefixed field names from the schema + $prefixedFieldNames = $discovery->getStoreAppFormFieldNames($className); + $prefix = PolydockAppStoreFields::FIELD_PREFIX; + + // Build a list of original (unprefixed) field names + $originalFieldNames = []; + foreach ($prefixedFieldNames as $prefixedName) { + if (str_starts_with($prefixedName, $prefix)) { + $originalFieldNames[] = substr($prefixedName, strlen($prefix)); + } + } + + // Extract fields that match the original field names from form data + foreach ($originalFieldNames as $fieldName) { + if (array_key_exists($fieldName, $data)) { + $appConfig[$fieldName] = $data[$fieldName]; + unset($data[$fieldName]); + } + } + } + + // Store the app config as JSON + $data['app_config'] = ! empty($appConfig) ? $appConfig : null; + + return $data; + } } diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php index cdad848..a1983fb 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php @@ -10,10 +10,25 @@ class ViewPolydockStoreApp extends ViewRecord { protected static string $resource = PolydockStoreAppResource::class; + #[\Override] protected function getHeaderActions(): array { return [ Actions\EditAction::make(), ]; } + + #[\Override] + protected function mutateFormDataBeforeFill(array $data): array + { + // Load custom field values from app_config JSON column + // Fields are stored without prefix, so we load them directly with their original names + $appConfig = $this->record->app_config ?? []; + + foreach ($appConfig as $key => $value) { + $data[$key] = $value; + } + + return $data; + } } diff --git a/app/Jobs/ProcessUserRemoteRegistration.php b/app/Jobs/ProcessUserRemoteRegistration.php index c0c016e..5b19c17 100644 --- a/app/Jobs/ProcessUserRemoteRegistration.php +++ b/app/Jobs/ProcessUserRemoteRegistration.php @@ -10,6 +10,8 @@ use App\Models\User; use App\Models\UserGroup; use App\Models\UserRemoteRegistration; +use App\Services\PolydockAppClassDiscovery; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppInstanceFields; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -304,6 +306,9 @@ private function handleRequestTrial(): void } $allocatedInstance->save(); + + // Store instance config fields as PolydockVariables + $this->storeInstanceConfigFields($allocatedInstance, $trialApp); } } catch (\Exception $e) { Log::error('Failed to process trial registration', [ @@ -340,4 +345,42 @@ private function handleUnknownType(): void $this->registration->status = UserRemoteRegistrationStatusEnum::FAILED; $this->registration->setResultValue('message', 'Unknown registration type'); } + + /** + * Store instance config fields as PolydockVariables on the app instance. + * + * Extracts fields prefixed with 'instance_config_' from the registration request data + * and stores them as PolydockVariables, respecting encryption settings. + * + * @param \App\Models\PolydockAppInstance $appInstance The app instance to store variables on + * @param PolydockStoreApp $storeApp The store app to get field schema from + */ + private function storeInstanceConfigFields($appInstance, PolydockStoreApp $storeApp): void + { + $instanceConfigPrefix = PolydockAppInstanceFields::FIELD_PREFIX; + $requestData = $this->registration->request_data ?? []; + + // Get field encryption map from the app class schema + $discovery = app(PolydockAppClassDiscovery::class); + $schema = $discovery->getAppInstanceFormSchema($storeApp->polydock_app_class ?? ''); + $encryptionMap = $discovery->getFieldEncryptionMap($schema); + + $storedFields = []; + + foreach ($requestData as $key => $value) { + if (str_starts_with((string) $key, $instanceConfigPrefix) && $value !== null && $value !== '') { + $encrypted = $encryptionMap[$key] ?? false; + $appInstance->setPolydockVariableValue($key, (string) $value, $encrypted); + $storedFields[] = $key; + } + } + + if (! empty($storedFields)) { + Log::info('Stored instance config fields as PolydockVariables', [ + 'registration_id' => $this->registration->id, + 'app_instance_id' => $appInstance->id, + 'fields' => $storedFields, + ]); + } + } } diff --git a/app/Models/PolydockAppInstance.php b/app/Models/PolydockAppInstance.php index 77edf7a..39691d9 100644 --- a/app/Models/PolydockAppInstance.php +++ b/app/Models/PolydockAppInstance.php @@ -226,6 +226,7 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface * * @return string */ + #[\Override] public function getRouteKeyName() { return 'uuid'; @@ -234,6 +235,7 @@ public function getRouteKeyName() /** * Boot the model. */ + #[\Override] protected static function boot() { parent::boot(); @@ -382,11 +384,28 @@ public function getApp(): PolydockAppInterface return $this->app; } + /** + * Set name of app instance + */ + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + /** * Set the type of the app instance * * @param string $appType The type of the app instance * @return self Returns the instance for method chaining + * + * @throws PolydockEngineAppNotFoundException */ public function setAppType(string $appType): self { diff --git a/app/Models/PolydockStoreApp.php b/app/Models/PolydockStoreApp.php index 32be63f..026b7a8 100644 --- a/app/Models/PolydockStoreApp.php +++ b/app/Models/PolydockStoreApp.php @@ -19,6 +19,7 @@ class PolydockStoreApp extends Model protected $fillable = [ 'polydock_store_id', 'polydock_app_class', + 'app_config', 'name', 'description', 'author', @@ -67,6 +68,7 @@ class PolydockStoreApp extends Model protected $casts = [ 'status' => PolydockStoreAppStatusEnum::class, + 'app_config' => 'array', 'available_for_trials' => 'boolean', 'target_unallocated_app_instances' => 'integer', 'send_midtrial_email' => 'boolean', diff --git a/app/Models/User.php b/app/Models/User.php index 93c4247..983956b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -82,7 +82,9 @@ public function getNameAttribute(): string */ public function groups() { - return $this->belongsToMany(UserGroup::class); + return $this->belongsToMany(UserGroup::class, 'user_user_group', 'user_id', 'user_group_id') + ->withPivot('role') + ->withTimestamps(); } /** diff --git a/app/Models/UserGroup.php b/app/Models/UserGroup.php index 82616c2..b60d46b 100644 --- a/app/Models/UserGroup.php +++ b/app/Models/UserGroup.php @@ -25,7 +25,9 @@ class UserGroup extends Model */ public function users() { - return $this->belongsToMany(User::class); + return $this->belongsToMany(User::class, 'user_user_group', 'user_group_id', 'user_id') + ->withPivot('role') + ->withTimestamps(); } /** diff --git a/app/PolydockEngine/Engine.php b/app/PolydockEngine/Engine.php index 1ffda20..b72b735 100644 --- a/app/PolydockEngine/Engine.php +++ b/app/PolydockEngine/Engine.php @@ -7,6 +7,7 @@ use FreedomtechHosting\PolydockApp\Exceptions\PolydockEngineProcessPolydockAppInstanceStatusException; use FreedomtechHosting\PolydockApp\PolydockAppInstanceInterface; use FreedomtechHosting\PolydockApp\PolydockAppInstanceStatusFlowException; +use FreedomtechHosting\PolydockApp\PolydockAppInterface; use FreedomtechHosting\PolydockApp\PolydockAppLoggerInterface; use FreedomtechHosting\PolydockApp\PolydockEngineBase; use FreedomtechHosting\PolydockApp\PolydockEngineInterface; @@ -133,6 +134,12 @@ public function processPolydockAppInstance(PolydockAppInstanceInterface $appInst throw new PolydockEngineAppNotFoundException('Class '.$polydockAppClass.' not found'); } + if (! is_subclass_of($polydockAppClass, PolydockAppInterface::class)) { + throw new PolydockEngineAppNotFoundException( + 'Class '.$polydockAppClass.' does not implement PolydockAppInterface' + ); + } + $app = new $polydockAppClass( $appInstance->storeApp->name, $appInstance->storeApp->description, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 91a42ea..ff22869 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; +use App\Services\PolydockAppClassDiscovery; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -14,7 +15,7 @@ class AppServiceProvider extends ServiceProvider #[\Override] public function register(): void { - // + $this->app->singleton(PolydockAppClassDiscovery::class); } /** diff --git a/app/Services/PolydockAppClassDiscovery.php b/app/Services/PolydockAppClassDiscovery.php new file mode 100644 index 0000000..6c0a3f0 --- /dev/null +++ b/app/Services/PolydockAppClassDiscovery.php @@ -0,0 +1,612 @@ +|null + */ + private ?array $cache = null; + + /** + * Discover all concrete classes that implement PolydockAppInterface. + * + * @return array FQCN => human-readable label + */ + public function getAvailableAppClasses(): array + { + if ($this->cache !== null) { + return $this->cache; + } + + $classes = []; + + foreach (array_keys($this->getClassMap()) as $className) { + if (! $this->matchesNamespaceFilter($className)) { + continue; + } + + try { + if (! class_exists($className, true)) { + continue; + } + + $reflection = new ReflectionClass($className); + } catch (\Throwable) { + continue; + } + + if ($reflection->isAbstract() || $reflection->isInterface()) { + continue; + } + + if (! $reflection->implementsInterface(PolydockAppInterface::class)) { + continue; + } + + $label = $this->buildLabel($reflection); + $classes[$className] = $label; + } + + ksort($classes); + $this->cache = $classes; + + return $classes; + } + + /** + * Build a human-readable label for the given class. + * + * If the class has a #[PolydockAppTitle] attribute, use its title. + * Otherwise, fall back to "ShortName (Namespace)" format. + * + * @param ReflectionClass $reflection + */ + private function buildLabel(ReflectionClass $reflection): string + { + $attributes = $reflection->getAttributes(PolydockAppTitle::class); + + if (! empty($attributes)) { + /** @var PolydockAppTitle $titleAttr */ + $titleAttr = $attributes[0]->newInstance(); + + return $titleAttr->title; + } + + // Fallback: ShortName (Namespace) + $shortName = $reflection->getShortName(); + $namespace = $reflection->getNamespaceName(); + + return "{$shortName} ({$namespace})"; + } + + /** + * Check whether a given class name is a valid, concrete PolydockAppInterface implementation. + */ + public function isValidAppClass(string $className): bool + { + return isset($this->getAvailableAppClasses()[$className]); + } + + /** + * Clear the cached discovery results. + */ + public function clearCache(): void + { + $this->cache = null; + } + + /** + * Get the custom store app form schema for a given class. + * Field names are automatically prefixed with 'app_config_'. + * + * @param string $className The fully qualified class name + * @return array<\Filament\Forms\Components\Component> Array of Filament form components + */ + public function getStoreAppFormSchema(string $className): array + { + if (empty($className)) { + \Log::debug('getStoreAppFormSchema: Empty class name provided'); + + return []; + } + + if (! $this->isValidAppClass($className)) { + \Log::debug('getStoreAppFormSchema: Invalid app class', ['className' => $className]); + + return []; + } + + try { + $reflection = new ReflectionClass($className); + $attributes = $reflection->getAttributes(PolydockAppStoreFields::class); + + if (empty($attributes)) { + \Log::debug('getStoreAppFormSchema: No PolydockAppStoreFields attribute found', ['className' => $className]); + + return []; + } + + /** @var PolydockAppStoreFields $attr */ + $attr = $attributes[0]->newInstance(); + $methodName = $attr->formMethod; + + if (! method_exists($className, $methodName)) { + \Log::debug('getStoreAppFormSchema: Method does not exist', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + return []; + } + + \Log::debug('getStoreAppFormSchema: Calling schema method', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + $schema = $className::$methodName(); + + \Log::debug('getStoreAppFormSchema: Schema retrieved', [ + 'className' => $className, + 'schemaCount' => count($schema), + ]); + + // Prefix all field names with 'app_config_' + return $this->prefixSchemaFieldNames($schema); + } catch (\Throwable $e) { + \Log::error('getStoreAppFormSchema: Exception thrown', [ + 'className' => $className, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + return []; + } + } + + /** + * Get the custom store app infolist schema for a given class. + * Field names are automatically prefixed with 'app_config_'. + * + * @param string $className The fully qualified class name + * @return array<\Filament\Infolists\Components\Component> Array of Filament infolist components + */ + public function getStoreAppInfolistSchema(string $className): array + { + if (empty($className)) { + Log::debug('getStoreAppInfolistSchema: Empty class name provided'); + + return []; + } + + if (! $this->isValidAppClass($className)) { + Log::debug('getStoreAppInfolistSchema: Invalid app class', ['className' => $className]); + + return []; + } + + try { + $reflection = new ReflectionClass($className); + $attributes = $reflection->getAttributes(PolydockAppStoreFields::class); + + if (empty($attributes)) { + Log::debug('getStoreAppInfolistSchema: No PolydockAppStoreFields attribute found', ['className' => $className]); + + return []; + } + + /** @var PolydockAppStoreFields $attr */ + $attr = $attributes[0]->newInstance(); + $methodName = $attr->infolistMethod; + + if (! method_exists($className, $methodName)) { + Log::debug('getStoreAppInfolistSchema: Method does not exist', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + return []; + } + + Log::debug('getStoreAppInfolistSchema: Calling schema method', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + $schema = $className::$methodName(); + + Log::debug('getStoreAppInfolistSchema: Schema retrieved', [ + 'className' => $className, + 'schemaCount' => count($schema), + ]); + + // Prefix all field names with 'app_config_' + return $this->prefixSchemaFieldNames($schema); + } catch (\Throwable $e) { + Log::error('getStoreAppInfolistSchema: Exception thrown', [ + 'className' => $className, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + return []; + } + } + + /** + * Get field names from a store app form schema (with prefix). + * + * @param string $className The fully qualified class name + * @return array Array of prefixed field names + */ + public function getStoreAppFormFieldNames(string $className): array + { + $schema = $this->getStoreAppFormSchema($className); + + return $this->extractFieldNamesFromSchema($schema); + } + + /** + * Get the custom app instance form schema for a given class. + * Field names are automatically prefixed with 'instance_config_'. + * + * @param string $className The fully qualified class name + * @return array<\Filament\Forms\Components\Component> Array of Filament form components + */ + public function getAppInstanceFormSchema(string $className): array + { + if (empty($className)) { + Log::debug('getAppInstanceFormSchema: Empty class name provided'); + + return []; + } + + if (! $this->isValidAppClass($className)) { + Log::debug('getAppInstanceFormSchema: Invalid app class', ['className' => $className]); + + return []; + } + + try { + $reflection = new ReflectionClass($className); + $attributes = $reflection->getAttributes(PolydockAppInstanceFields::class); + + if (empty($attributes)) { + Log::debug('getAppInstanceFormSchema: No PolydockAppInstanceFields attribute found', ['className' => $className]); + + return []; + } + + /** @var PolydockAppInstanceFields $attr */ + $attr = $attributes[0]->newInstance(); + $methodName = $attr->formMethod; + + if (! method_exists($className, $methodName)) { + Log::debug('getAppInstanceFormSchema: Method does not exist', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + return []; + } + + Log::debug('getAppInstanceFormSchema: Calling schema method', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + $schema = $className::$methodName(); + + Log::debug('getAppInstanceFormSchema: Schema retrieved', [ + 'className' => $className, + 'schemaCount' => count($schema), + ]); + + // Prefix all field names with 'instance_config_' + return $this->prefixAppInstanceSchemaFieldNames($schema); + } catch (\Throwable $e) { + Log::error('getAppInstanceFormSchema: Exception thrown', [ + 'className' => $className, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + return []; + } + } + + /** + * Get the custom app instance infolist schema for a given class. + * Field names are automatically prefixed with 'instance_config_'. + * + * @param string $className The fully qualified class name + * @return array<\Filament\Infolists\Components\Component> Array of Filament infolist components + */ + public function getAppInstanceInfolistSchema(string $className): array + { + if (empty($className)) { + Log::debug('getAppInstanceInfolistSchema: Empty class name provided'); + + return []; + } + + if (! $this->isValidAppClass($className)) { + Log::debug('getAppInstanceInfolistSchema: Invalid app class', ['className' => $className]); + + return []; + } + + try { + $reflection = new ReflectionClass($className); + $attributes = $reflection->getAttributes(PolydockAppInstanceFields::class); + + if (empty($attributes)) { + Log::debug('getAppInstanceInfolistSchema: No PolydockAppInstanceFields attribute found', ['className' => $className]); + + return []; + } + + /** @var PolydockAppInstanceFields $attr */ + $attr = $attributes[0]->newInstance(); + $methodName = $attr->infolistMethod; + + if (! method_exists($className, $methodName)) { + Log::debug('getAppInstanceInfolistSchema: Method does not exist', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + return []; + } + + Log::debug('getAppInstanceInfolistSchema: Calling schema method', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + $schema = $className::$methodName(); + + Log::debug('getAppInstanceInfolistSchema: Schema retrieved', [ + 'className' => $className, + 'schemaCount' => count($schema), + ]); + + // Prefix all field names with 'instance_config_' + return $this->prefixAppInstanceSchemaFieldNames($schema); + } catch (\Throwable $e) { + Log::error('getAppInstanceInfolistSchema: Exception thrown', [ + 'className' => $className, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + return []; + } + } + + /** + * Get field names from an app instance form schema (with prefix). + * + * @param string $className The fully qualified class name + * @return array Array of prefixed field names + */ + public function getAppInstanceFormFieldNames(string $className): array + { + $schema = $this->getAppInstanceFormSchema($className); + + return $this->extractFieldNamesFromSchema($schema); + } + + /** + * Recursively prefix field names in Filament schema components for App Instance. + * + * @return array + */ + private function prefixAppInstanceSchemaFieldNames(array $components): array + { + $prefix = PolydockAppInstanceFields::FIELD_PREFIX; + + foreach ($components as $component) { + // Prefix the field name if this component has one + if (method_exists($component, 'getName') && method_exists($component, 'name')) { + $name = $component->getName(); + if ($name !== null && ! str_starts_with($name, $prefix)) { + $component->name($prefix.$name); + } + } + + // Recursively process child schema (for Sections, Grids, etc.) + if (method_exists($component, 'getChildComponents') && method_exists($component, 'schema')) { + $children = $component->getChildComponents(); + if (! empty($children)) { + $component->schema($this->prefixAppInstanceSchemaFieldNames($children)); + } + } + } + + return $components; + } + + /** + * Recursively prefix field names in Filament schema components. + * + * @return array + */ + private function prefixSchemaFieldNames(array $components): array + { + $prefix = PolydockAppStoreFields::FIELD_PREFIX; + + foreach ($components as $component) { + // Prefix the field name if this component has one + if (method_exists($component, 'getName') && method_exists($component, 'name')) { + $name = $component->getName(); + if ($name !== null && ! str_starts_with($name, $prefix)) { + $component->name($prefix.$name); + } + } + + // Recursively process child schema (for Sections, Grids, etc.) + if (method_exists($component, 'getChildComponents') && method_exists($component, 'schema')) { + $children = $component->getChildComponents(); + if (! empty($children)) { + $component->schema($this->prefixSchemaFieldNames($children)); + } + } + } + + return $components; + } + + /** + * Recursively extract field names from Filament schema components. + * + * @return array + */ + private function extractFieldNamesFromSchema(array $components): array + { + $names = []; + + foreach ($components as $component) { + // Get the field name if this component has one + if (method_exists($component, 'getName')) { + $name = $component->getName(); + if ($name !== null) { + $names[] = $name; + } + } + + // Recursively check child schema (for Sections, Grids, etc.) + if (method_exists($component, 'getChildComponents')) { + $children = $component->getChildComponents(); + if (! empty($children)) { + $names = array_merge($names, $this->extractFieldNamesFromSchema($children)); + } + } + } + + return $names; + } + + /** + * Check if a field should be stored encrypted based on extraAttributes. + * + * @param mixed $component + */ + public function isFieldEncrypted($component): bool + { + if (! method_exists($component, 'getExtraAttributes')) { + return false; + } + + $extraAttributes = $component->getExtraAttributes(); + + return isset($extraAttributes['encrypted']) && $extraAttributes['encrypted'] === true; + } + + /** + * Get encryption map for all fields in a schema. + * + * @return array fieldName => isEncrypted + */ + public function getFieldEncryptionMap(array $components): array + { + $map = []; + + foreach ($components as $component) { + if (method_exists($component, 'getName')) { + $name = $component->getName(); + if ($name !== null) { + $map[$name] = $this->isFieldEncrypted($component); + } + } + + if (method_exists($component, 'getChildComponents')) { + $children = $component->getChildComponents(); + if (! empty($children)) { + $map = array_merge($map, $this->getFieldEncryptionMap($children)); + } + } + } + + return $map; + } + + /** + * Check if a class name matches any of the namespace filters. + */ + private function matchesNamespaceFilter(string $className): bool + { + foreach (self::NAMESPACE_FILTERS as $filter) { + if (stripos($className, $filter) !== false) { + return true; + } + } + + return false; + } + + /** + * Get Composer's merged class map from all registered loaders. + * + * @return array className => filePath + */ + protected function getClassMap(): array + { + /** @var ClassLoader[] $loaders */ + $loaders = ClassLoader::getRegisteredLoaders(); + + $classMap = []; + foreach ($loaders as $loader) { + $classMap = array_merge($classMap, $loader->getClassMap()); + } + + return $classMap; + } +} diff --git a/composer.json b/composer.json index 8be96c9..47b1130 100644 --- a/composer.json +++ b/composer.json @@ -7,13 +7,14 @@ "license": "MIT", "require": { "php": "^8.3", - "amazeeio/polydock-app-amazeeio-privategpt": "v0.0.4", + "ext-curl": "*", + "amazeeio/polydock-app-amazeeio-privategpt": "^0.0.11", "evanschleret/lara-mjml": "^0.3.0", "filament/filament": "^3.2", - "freedomtech-hosting/ft-lagoon-php": "^0.0.5", - "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.5", - "freedomtech-hosting/polydock-app": "^0.0.26", - "freedomtech-hosting/polydock-app-amazeeio-generic": "^0.0.73", + "freedomtech-hosting/ft-lagoon-php": "^0.0.11", + "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.6", + "freedomtech-hosting/polydock-app": "^0.0.30", + "freedomtech-hosting/polydock-app-amazeeio-generic": "^0.0.79", "laravel/framework": "^11.31", "laravel/horizon": "^5.30", "laravel/sanctum": "^4.0", @@ -23,6 +24,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.9", "laravel/pail": "^1.1", "laravel/pint": "^1.13", "laravel/sail": "^1.26", diff --git a/composer.lock b/composer.lock index ddbfe4f..2cebc25 100644 --- a/composer.lock +++ b/composer.lock @@ -4,27 +4,27 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "322d3905648cbba84d21c3f294546c88", + "content-hash": "227105b87cebeb9d8e33b0ad823e68d3", "packages": [ { "name": "amazeeio/polydock-app-amazeeio-privategpt", - "version": "v0.0.4", + "version": "v0.0.11", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-app-amazeeio-privategpt.git", - "reference": "be5c3864e0262a94af8363ca3e4627f59e4a4d31" + "reference": "f19eb47855515e104bf64d3cb78e538be84af641" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amazeeio/polydock-app-amazeeio-privategpt/zipball/be5c3864e0262a94af8363ca3e4627f59e4a4d31", - "reference": "be5c3864e0262a94af8363ca3e4627f59e4a4d31", + "url": "https://api.github.com/repos/amazeeio/polydock-app-amazeeio-privategpt/zipball/f19eb47855515e104bf64d3cb78e538be84af641", + "reference": "f19eb47855515e104bf64d3cb78e538be84af641", "shasum": "" }, "require": { "cuyz/valinor": "^1.0", - "freedomtech-hosting/ft-lagoon-php": "^0.0.5", - "freedomtech-hosting/polydock-app": "^0.0.26", - "freedomtech-hosting/polydock-app-amazeeio-generic": "*", + "freedomtech-hosting/ft-lagoon-php": "^0.0.11", + "freedomtech-hosting/polydock-app": "^0.0.30", + "freedomtech-hosting/polydock-app-amazeeio-generic": "^0.0.79", "guzzlehttp/guzzle": "^7.0" }, "require-dev": { @@ -55,9 +55,9 @@ "description": "Polydock App - amazee.io PrivateGPT with Direct API Integration", "support": { "issues": "https://github.com/amazeeio/polydock-app-amazeeio-privategpt/issues", - "source": "https://github.com/amazeeio/polydock-app-amazeeio-privategpt/tree/v0.0.4" + "source": "https://github.com/amazeeio/polydock-app-amazeeio-privategpt/tree/v0.0.11" }, - "time": "2025-11-25T19:59:39+00:00" + "time": "2026-02-18T20:41:03+00:00" }, { "name": "anourvalar/eloquent-serialize", @@ -277,16 +277,16 @@ }, { "name": "brick/math", - "version": "0.14.5", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/618a8077b3c326045e10d5788ed713b341fcfe40", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -325,7 +325,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.5" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -333,7 +333,7 @@ "type": "github" } ], - "time": "2026-02-03T18:06:51+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -770,29 +770,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -812,9 +812,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/inflector", @@ -1183,16 +1183,16 @@ }, { "name": "filament/actions", - "version": "v3.3.47", + "version": "v3.3.48", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1" + "reference": "3cb3e1f9094ed3b4bc102616966c365138c908bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1", - "reference": "f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/3cb3e1f9094ed3b4bc102616966c365138c908bc", + "reference": "3cb3e1f9094ed3b4bc102616966c365138c908bc", "shasum": "" }, "require": { @@ -1232,20 +1232,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:27+00:00" + "time": "2026-02-07T21:52:11+00:00" }, { "name": "filament/filament", - "version": "v3.3.47", + "version": "v3.3.48", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "790e3c163e93f5746beea88b93d38673424984b6" + "reference": "6098e568b4257dc438ff68aced0a260f06ba6d52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/790e3c163e93f5746beea88b93d38673424984b6", - "reference": "790e3c163e93f5746beea88b93d38673424984b6", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/6098e568b4257dc438ff68aced0a260f06ba6d52", + "reference": "6098e568b4257dc438ff68aced0a260f06ba6d52", "shasum": "" }, "require": { @@ -1297,20 +1297,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:34+00:00" + "time": "2026-02-07T21:52:21+00:00" }, { "name": "filament/forms", - "version": "v3.3.47", + "version": "v3.3.48", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58" + "reference": "45f6e9337d60154f2d0ad3b2c4e47f7e16912971" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/f708ce490cff3770071d18e9ea678eb4b7c65c58", - "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/45f6e9337d60154f2d0ad3b2c4e47f7e16912971", + "reference": "45f6e9337d60154f2d0ad3b2c4e47f7e16912971", "shasum": "" }, "require": { @@ -1353,20 +1353,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:33+00:00" + "time": "2026-02-07T21:51:43+00:00" }, { "name": "filament/infolists", - "version": "v3.3.47", + "version": "v3.3.48", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "ac7fc1c8acc651c6c793696f0772747791c91155" + "reference": "9cef7bf9f46756a8adf762ced62952e8c239b840" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/ac7fc1c8acc651c6c793696f0772747791c91155", - "reference": "ac7fc1c8acc651c6c793696f0772747791c91155", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/9cef7bf9f46756a8adf762ced62952e8c239b840", + "reference": "9cef7bf9f46756a8adf762ced62952e8c239b840", "shasum": "" }, "require": { @@ -1404,11 +1404,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:28:31+00:00" + "time": "2026-02-07T21:51:54+00:00" }, { "name": "filament/notifications", - "version": "v3.3.47", + "version": "v3.3.48", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", @@ -1460,16 +1460,16 @@ }, { "name": "filament/support", - "version": "v3.3.47", + "version": "v3.3.48", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "c37f4b9045a7c514974e12562b5a41813860b505" + "reference": "493bd79d4f4ae7b9256c317af742354318a6f4e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/c37f4b9045a7c514974e12562b5a41813860b505", - "reference": "c37f4b9045a7c514974e12562b5a41813860b505", + "url": "https://api.github.com/repos/filamentphp/support/zipball/493bd79d4f4ae7b9256c317af742354318a6f4e0", + "reference": "493bd79d4f4ae7b9256c317af742354318a6f4e0", "shasum": "" }, "require": { @@ -1515,20 +1515,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-09T09:01:14+00:00" + "time": "2026-02-07T21:51:58+00:00" }, { "name": "filament/tables", - "version": "v3.3.47", + "version": "v3.3.48", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "c88d17248827b3fbca09db53d563498d29c6b180" + "reference": "fb0ab986950dc8129725f676bdb310851b18403f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/c88d17248827b3fbca09db53d563498d29c6b180", - "reference": "c88d17248827b3fbca09db53d563498d29c6b180", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/fb0ab986950dc8129725f676bdb310851b18403f", + "reference": "fb0ab986950dc8129725f676bdb310851b18403f", "shasum": "" }, "require": { @@ -1567,20 +1567,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:37+00:00" + "time": "2026-02-07T21:52:06+00:00" }, { "name": "filament/widgets", - "version": "v3.3.47", + "version": "v3.3.48", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "2bf59fd94007b69c22c161f7a4749ea19560e03e" + "reference": "f58ff26e81ca2557205e3111e1d9d05c258cc206" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/2bf59fd94007b69c22c161f7a4749ea19560e03e", - "reference": "2bf59fd94007b69c22c161f7a4749ea19560e03e", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/f58ff26e81ca2557205e3111e1d9d05c258cc206", + "reference": "f58ff26e81ca2557205e3111e1d9d05c258cc206", "shasum": "" }, "require": { @@ -1611,20 +1611,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:32+00:00" + "time": "2026-02-07T21:51:58+00:00" }, { "name": "freedomtech-hosting/ft-lagoon-php", - "version": "v0.0.5", + "version": "v0.0.11", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-lagoon-php-lib.git", - "reference": "e8669ab79091eade0444a1fe7bcf96ed243b8ea4" + "reference": "f9c12b2be5b204c6ba37a64c86ace8af4745da23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amazeeio/polydock-lagoon-php-lib/zipball/e8669ab79091eade0444a1fe7bcf96ed243b8ea4", - "reference": "e8669ab79091eade0444a1fe7bcf96ed243b8ea4", + "url": "https://api.github.com/repos/amazeeio/polydock-lagoon-php-lib/zipball/f9c12b2be5b204c6ba37a64c86ace8af4745da23", + "reference": "f9c12b2be5b204c6ba37a64c86ace8af4745da23", "shasum": "" }, "require": { @@ -1651,25 +1651,26 @@ "description": "The Freedom Tech Lagoon PHP library", "support": { "issues": "https://github.com/amazeeio/polydock-lagoon-php-lib/issues", - "source": "https://github.com/amazeeio/polydock-lagoon-php-lib/tree/v0.0.5" + "source": "https://github.com/amazeeio/polydock-lagoon-php-lib/tree/v0.0.11" }, - "time": "2025-04-22T03:56:15+00:00" + "time": "2026-02-17T11:31:32+00:00" }, { "name": "freedomtech-hosting/polydock-amazeeai-backend-client-php", - "version": "v0.0.5", + "version": "v0.0.6", "source": { "type": "git", - "url": "https://github.com/Freedomtech-Hosting/polydock-amazeeai-backend-client-php.git", - "reference": "5b0c8ac37cc4db7fb69d34a5d09875f4a342760b" + "url": "https://github.com/amazeeio/polydock-amazeeai-backend-php-lib.git", + "reference": "f4841e97eabe7c78bda35e146dfd9b9d13dddb4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Freedomtech-Hosting/polydock-amazeeai-backend-client-php/zipball/5b0c8ac37cc4db7fb69d34a5d09875f4a342760b", - "reference": "5b0c8ac37cc4db7fb69d34a5d09875f4a342760b", + "url": "https://api.github.com/repos/amazeeio/polydock-amazeeai-backend-php-lib/zipball/f4841e97eabe7c78bda35e146dfd9b9d13dddb4f", + "reference": "f4841e97eabe7c78bda35e146dfd9b9d13dddb4f", "shasum": "" }, "require": { + "ext-curl": "*", "symfony/http-client": "^7.2" }, "type": "library", @@ -1690,23 +1691,23 @@ ], "description": "The Freedom Tech amazee.ai Private AI PHP library", "support": { - "issues": "https://github.com/Freedomtech-Hosting/polydock-amazeeai-backend-client-php/issues", - "source": "https://github.com/Freedomtech-Hosting/polydock-amazeeai-backend-client-php/tree/v0.0.5" + "issues": "https://github.com/amazeeio/polydock-amazeeai-backend-php-lib/issues", + "source": "https://github.com/amazeeio/polydock-amazeeai-backend-php-lib/tree/v0.0.6" }, - "time": "2025-03-24T02:16:39+00:00" + "time": "2026-02-13T00:30:17+00:00" }, { "name": "freedomtech-hosting/polydock-app", - "version": "v0.0.26", + "version": "v0.0.30", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-app-lib.git", - "reference": "aa6cfc7601b6eeedb6e04e01aaa61b1e84b6b00c" + "reference": "b779e9dac1b8fea63e9f617931991c840674436c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amazeeio/polydock-app-lib/zipball/aa6cfc7601b6eeedb6e04e01aaa61b1e84b6b00c", - "reference": "aa6cfc7601b6eeedb6e04e01aaa61b1e84b6b00c", + "url": "https://api.github.com/repos/amazeeio/polydock-app-lib/zipball/b779e9dac1b8fea63e9f617931991c840674436c", + "reference": "b779e9dac1b8fea63e9f617931991c840674436c", "shasum": "" }, "require": { @@ -1731,28 +1732,28 @@ "description": "Library for Polydock Apps", "support": { "issues": "https://github.com/amazeeio/polydock-app-lib/issues", - "source": "https://github.com/amazeeio/polydock-app-lib/tree/v0.0.26" + "source": "https://github.com/amazeeio/polydock-app-lib/tree/v0.0.30" }, - "time": "2025-04-11T19:57:05+00:00" + "time": "2026-02-13T02:15:27+00:00" }, { "name": "freedomtech-hosting/polydock-app-amazeeio-generic", - "version": "v0.0.73", + "version": "v0.0.79", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-app-amazeeio-generic.git", - "reference": "54df7bed96e00824d6e765ae5fa06e9823f71830" + "reference": "ab18815f8f26d605e9af32dc5e551ce3fb2fc81d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amazeeio/polydock-app-amazeeio-generic/zipball/54df7bed96e00824d6e765ae5fa06e9823f71830", - "reference": "54df7bed96e00824d6e765ae5fa06e9823f71830", + "url": "https://api.github.com/repos/amazeeio/polydock-app-amazeeio-generic/zipball/ab18815f8f26d605e9af32dc5e551ce3fb2fc81d", + "reference": "ab18815f8f26d605e9af32dc5e551ce3fb2fc81d", "shasum": "" }, "require": { - "freedomtech-hosting/ft-lagoon-php": "^0.0.5", - "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.5", - "freedomtech-hosting/polydock-app": "^0.0.26" + "freedomtech-hosting/ft-lagoon-php": "^0.0.11", + "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.6", + "freedomtech-hosting/polydock-app": "^0.0.30" }, "type": "library", "autoload": { @@ -1773,9 +1774,9 @@ "description": "Polydock App - amazee.io Generic", "support": { "issues": "https://github.com/amazeeio/polydock-app-amazeeio-generic/issues", - "source": "https://github.com/amazeeio/polydock-app-amazeeio-generic/tree/v0.0.73" + "source": "https://github.com/amazeeio/polydock-app-amazeeio-generic/tree/v0.0.79" }, - "time": "2025-06-25T20:14:15+00:00" + "time": "2026-02-18T20:37:21+00:00" }, { "name": "fruitcake/php-cors", @@ -2601,36 +2602,36 @@ }, { "name": "laravel/horizon", - "version": "v5.43.0", + "version": "v5.44.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de" + "reference": "00c21e4e768112cce3f4fe576d75956dfc423de2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/2a04285ba83915511afbe987cbfedafdc27fd2de", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de", + "url": "https://api.github.com/repos/laravel/horizon/zipball/00c21e4e768112cce3f4fe576d75956dfc423de2", + "reference": "00c21e4e768112cce3f4fe576d75956dfc423de2", "shasum": "" }, "require": { "ext-json": "*", "ext-pcntl": "*", "ext-posix": "*", - "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0", - "illuminate/queue": "^9.21|^10.0|^11.0|^12.0", - "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.17|^3.0", "php": "^8.0", "ramsey/uuid": "^4.0", - "symfony/console": "^6.0|^7.0", - "symfony/error-handler": "^6.0|^7.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/error-handler": "^6.0|^7.0|^8.0", "symfony/polyfill-php83": "^1.28", - "symfony/process": "^6.0|^7.0" + "symfony/process": "^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10|^2.0", "predis/predis": "^1.1|^2.0|^3.0" }, @@ -2674,36 +2675,36 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.43.0" + "source": "https://github.com/laravel/horizon/tree/v5.44.0" }, - "time": "2026-01-15T15:10:56+00:00" + "time": "2026-02-10T18:18:08+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.11", + "version": "v0.3.13", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217" + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", + "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -2733,36 +2734,36 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.11" + "source": "https://github.com/laravel/prompts/tree/v0.3.13" }, - "time": "2026-01-27T02:55:06+00:00" + "time": "2026-02-06T12:17:10+00:00" }, { "name": "laravel/sanctum", - "version": "v4.3.0", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a" + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/c978c82b2b8ab685468a7ca35224497d541b775a", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^11.0|^12.0", - "illuminate/contracts": "^11.0|^12.0", - "illuminate/database": "^11.0|^12.0", - "illuminate/support": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", "php": "^8.2", - "symfony/console": "^7.0" + "symfony/console": "^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.15|^10.8", + "orchestra/testbench": "^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -2798,31 +2799,31 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-01-22T22:27:01+00:00" + "time": "2026-02-07T17:19:31+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.8", + "version": "v2.0.9", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -2859,20 +2860,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2026-02-03T06:55:34+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.0", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -2923,9 +2924,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.0" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-12-19T19:16:45+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "league/commonmark", @@ -3644,16 +3645,16 @@ }, { "name": "livewire/livewire", - "version": "v3.7.8", + "version": "v3.7.10", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607" + "reference": "0dc679eb4c8b4470cb12522b5927ef08ca2358bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/06ec7e8cd61bb01739b8f26396db6fe73b7e0607", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607", + "url": "https://api.github.com/repos/livewire/livewire/zipball/0dc679eb4c8b4470cb12522b5927ef08ca2358bb", + "reference": "0dc679eb4c8b4470cb12522b5927ef08ca2358bb", "shasum": "" }, "require": { @@ -3708,7 +3709,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.8" + "source": "https://github.com/livewire/livewire/tree/v3.7.10" }, "funding": [ { @@ -3716,7 +3717,7 @@ "type": "github" } ], - "time": "2026-02-03T02:57:56+00:00" + "time": "2026-02-09T22:49:33+00:00" }, { "name": "masterminds/html5", @@ -3995,16 +3996,16 @@ }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.4", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", "shasum": "" }, "require": { @@ -4012,8 +4013,8 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/tester": "^2.6", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -4054,22 +4055,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.4" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-08T02:54:00+00:00" }, { "name": "nette/utils", - "version": "v4.1.2", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "shasum": "" }, "require": { @@ -4081,8 +4082,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -4143,9 +4146,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.2" + "source": "https://github.com/nette/utils/tree/v4.1.3" }, - "time": "2026-02-03T17:21:09+00:00" + "time": "2026-02-13T03:05:33+00:00" }, { "name": "nikic/php-parser", @@ -4207,31 +4210,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -4263,7 +4266,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -4274,7 +4277,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -4290,7 +4293,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "openspout/openspout", @@ -5152,16 +5155,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.19", + "version": "v0.12.20", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee" + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/a4f766e5c5b6773d8399711019bb7d90875a50ee", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", "shasum": "" }, "require": { @@ -5225,9 +5228,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.19" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" }, - "time": "2026-01-30T17:33:13+00:00" + "time": "2026-02-11T15:05:28+00:00" }, { "name": "ralouphie/getallheaders", @@ -5507,16 +5510,16 @@ }, { "name": "softonic/graphql-client", - "version": "3.1.1", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/softonic/graphql-client.git", - "reference": "72ae042d3f22ad5eb57cd7672954d8f5dc9687ef" + "reference": "71efe9e93609a98f7878c7d323732ccfb817653d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/softonic/graphql-client/zipball/72ae042d3f22ad5eb57cd7672954d8f5dc9687ef", - "reference": "72ae042d3f22ad5eb57cd7672954d8f5dc9687ef", + "url": "https://api.github.com/repos/softonic/graphql-client/zipball/71efe9e93609a98f7878c7d323732ccfb817653d", + "reference": "71efe9e93609a98f7878c7d323732ccfb817653d", "shasum": "" }, "require": { @@ -5524,7 +5527,7 @@ "guzzlehttp/guzzle": "^6.3 || ^7.0", "php": "^8.0", "softonic/guzzle-oauth2-middleware": "^2.1", - "symfony/console": "^6.0 || ^7.0" + "symfony/console": "^6.0 || ^7.0 || ^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.9", @@ -5556,9 +5559,9 @@ ], "support": { "issues": "https://github.com/softonic/graphql-client/issues", - "source": "https://github.com/softonic/graphql-client/tree/3.1.1" + "source": "https://github.com/softonic/graphql-client/tree/3.1.2" }, - "time": "2025-03-26T14:37:06+00:00" + "time": "2026-02-18T10:31:17+00:00" }, { "name": "softonic/guzzle-oauth2-middleware", @@ -5787,25 +5790,25 @@ }, { "name": "spatie/mjml-php", - "version": "1.2.5", + "version": "1.2.6", "source": { "type": "git", "url": "https://github.com/spatie/mjml-php.git", - "reference": "0af0b3944617889df7bed69faca3819a0399feff" + "reference": "8bf9e5966beb9521510a0af29d94bfffaa1f3dbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/mjml-php/zipball/0af0b3944617889df7bed69faca3819a0399feff", - "reference": "0af0b3944617889df7bed69faca3819a0399feff", + "url": "https://api.github.com/repos/spatie/mjml-php/zipball/8bf9e5966beb9521510a0af29d94bfffaa1f3dbd", + "reference": "8bf9e5966beb9521510a0af29d94bfffaa1f3dbd", "shasum": "" }, "require": { - "php": "^8.1", - "symfony/process": "^6.3.2|^7.0" + "php": "^8.2", + "symfony/process": "^6.3.2|^7.0|^8.0" }, "require-dev": { "laravel/pint": "^1.11", - "pestphp/pest": "^2.16", + "pestphp/pest": "^2.16|^3.0", "spatie/ray": "^1.37.2" }, "suggest": { @@ -5836,7 +5839,7 @@ ], "support": { "issues": "https://github.com/spatie/mjml-php/issues", - "source": "https://github.com/spatie/mjml-php/tree/1.2.5" + "source": "https://github.com/spatie/mjml-php/tree/1.2.6" }, "funding": [ { @@ -5844,7 +5847,7 @@ "type": "github" } ], - "time": "2025-03-17T19:50:19+00:00" + "time": "2026-02-09T15:00:49+00:00" }, { "name": "spatie/ssh", @@ -8979,39 +8982,171 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.9.2", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", + "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.32" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.9.2" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-01-30T15:16:32+00:00" + }, { "name": "laravel/pail", - "version": "v1.2.4", + "version": "v1.2.6", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", "shasum": "" }, "require": { "ext-mbstring": "*", - "illuminate/console": "^10.24|^11.0|^12.0", - "illuminate/contracts": "^10.24|^11.0|^12.0", - "illuminate/log": "^10.24|^11.0|^12.0", - "illuminate/process": "^10.24|^11.0|^12.0", - "illuminate/support": "^10.24|^11.0|^12.0", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", "nunomaduro/termwind": "^1.15|^2.0", "php": "^8.2", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", "pestphp/pest": "^2.20|^3.0|^4.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", "phpstan/phpstan": "^1.12.27", - "symfony/var-dumper": "^6.3|^7.0" + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, "type": "library", "extra": { @@ -9056,20 +9191,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-11-20T16:29:35+00:00" + "time": "2026-02-09T13:44:54+00:00" }, { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.27.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", "shasum": "" }, "require": { @@ -9080,13 +9215,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.93.1", + "illuminate/view": "^12.51.0", + "larastan/larastan": "^3.9.2", + "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "pestphp/pest": "^3.8.5" }, "bin": [ "builds/pint" @@ -9123,32 +9258,32 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2026-02-10T20:00:20+00:00" }, { "name": "laravel/sail", - "version": "v1.52.0", + "version": "v1.53.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", "shasum": "" }, "require": { - "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", - "symfony/console": "^6.0|^7.0", - "symfony/yaml": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" }, "require-dev": { - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", "phpstan/phpstan": "^2.0" }, "bin": [ @@ -9186,7 +9321,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-01-01T02:46:03+00:00" + "time": "2026-02-06T12:16:02+00:00" }, { "name": "mockery/mockery", @@ -9333,39 +9468,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.4 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" }, "type": "library", "extra": { @@ -9428,7 +9560,7 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "phar-io/manifest", @@ -9550,11 +9682,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.38", + "version": "2.1.39", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", - "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", "shasum": "" }, "require": { @@ -9599,7 +9731,7 @@ "type": "github" } ], - "time": "2026-01-30T17:12:46+00:00" + "time": "2026-02-11T14:48:56+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9950,16 +10082,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.50", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", - "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -9974,7 +10106,7 @@ "phar-io/version": "^3.2.1", "php": ">=8.2", "phpunit/php-code-coverage": "^11.0.12", - "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-file-iterator": "^5.1.1", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", @@ -9986,6 +10118,7 @@ "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", "sebastian/type": "^5.1.3", "sebastian/version": "^5.0.2", "staabm/side-effects-detector": "^1.0.5" @@ -10031,7 +10164,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.50" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -10055,25 +10188,25 @@ "type": "tidelift" } ], - "time": "2026-01-27T05:59:18+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "rector/rector", - "version": "2.3.5", + "version": "2.3.7", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070" + "reference": "9c46ad17f57963932c9788fd1b0f1d07ff450370" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9442f4037de6a5347ae157fe8e6c7cda9d909070", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/9c46ad17f57963932c9788fd1b0f1d07ff450370", + "reference": "9c46ad17f57963932c9788fd1b0f1d07ff450370", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.36" + "phpstan/phpstan": "^2.1.38" }, "conflict": { "rector/rector-doctrine": "*", @@ -10107,7 +10240,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.5" + "source": "https://github.com/rectorphp/rector/tree/2.3.7" }, "funding": [ { @@ -10115,7 +10248,7 @@ "type": "github" } ], - "time": "2026-01-28T15:22:48+00:00" + "time": "2026-02-19T14:44:16+00:00" }, { "name": "sebastian/cli-parser", @@ -11441,7 +11574,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.3", + "ext-curl": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/database/factories/PolydockStoreAppFactory.php b/database/factories/PolydockStoreAppFactory.php index 248390d..435fda5 100644 --- a/database/factories/PolydockStoreAppFactory.php +++ b/database/factories/PolydockStoreAppFactory.php @@ -13,7 +13,7 @@ class PolydockStoreAppFactory extends Factory public function definition(): array { return [ - 'polydock_app_class' => 'FreedomtechHosting\\PolydockApp'.fake()->word().'\\PolydockApp', + 'polydock_app_class' => \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class, 'name' => fake()->words(3, true), 'description' => fake()->paragraph(), 'author' => fake()->name(), diff --git a/database/migrations/2026_02_15_015738_add_app_config_to_polydock_store_apps_table.php b/database/migrations/2026_02_15_015738_add_app_config_to_polydock_store_apps_table.php new file mode 100644 index 0000000..9c541b2 --- /dev/null +++ b/database/migrations/2026_02_15_015738_add_app_config_to_polydock_store_apps_table.php @@ -0,0 +1,28 @@ +json('app_config')->nullable()->after('polydock_app_class'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('polydock_store_apps', function (Blueprint $table) { + $table->dropColumn('app_config'); + }); + } +}; diff --git a/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php b/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php index c7966d2..d32534e 100644 --- a/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php +++ b/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php @@ -1,5 +1,6 @@
+ @csrf {{ $this->form }}
diff --git a/tests/Unit/Services/PolydockAppClassDiscoveryTest.php b/tests/Unit/Services/PolydockAppClassDiscoveryTest.php new file mode 100644 index 0000000..606ec91 --- /dev/null +++ b/tests/Unit/Services/PolydockAppClassDiscoveryTest.php @@ -0,0 +1,357 @@ +discovery = app(PolydockAppClassDiscovery::class); + $this->discovery->clearCache(); + } + + // ────────────────────────────────────────────────────────────── + // Discovery: happy-path + // ────────────────────────────────────────────────────────────── + + public function test_returns_non_empty_array(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + + $this->assertNotEmpty($classes, 'Should discover at least one app class'); + $this->assertIsArray($classes); + } + + public function test_all_returned_classes_implement_interface(): void + { + foreach (array_keys($this->discovery->getAvailableAppClasses()) as $className) { + $this->assertTrue( + class_exists($className), + "Class {$className} should exist" + ); + + $reflection = new \ReflectionClass($className); + $this->assertTrue( + $reflection->implementsInterface(PolydockAppInterface::class), + "Class {$className} should implement PolydockAppInterface" + ); + } + } + + public function test_all_returned_classes_are_concrete(): void + { + foreach (array_keys($this->discovery->getAvailableAppClasses()) as $className) { + $reflection = new \ReflectionClass($className); + $this->assertFalse( + $reflection->isAbstract(), + "Class {$className} should not be abstract" + ); + $this->assertFalse( + $reflection->isInterface(), + "Class {$className} should not be an interface" + ); + } + } + + public function test_includes_known_app_classes(): void + { + $classNames = array_keys($this->discovery->getAvailableAppClasses()); + + $this->assertContains( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class, + $classNames, + 'Should include PolydockAiApp' + ); + + $this->assertContains( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockApp::class, + $classNames, + 'Should include PolydockApp' + ); + + $this->assertContains( + \Amazeeio\PolydockAppAmazeeioPrivateGpt\PolydockPrivateGptApp::class, + $classNames, + 'Should include PolydockPrivateGptApp' + ); + } + + // ────────────────────────────────────────────────────────────── + // Discovery: exclusion rules + // ────────────────────────────────────────────────────────────── + + public function test_excludes_interface(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + + $this->assertArrayNotHasKey( + PolydockAppInterface::class, + $classes, + 'Should not include the interface itself' + ); + } + + public function test_excludes_abstract_base_class(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + + $this->assertArrayNotHasKey( + PolydockAppBase::class, + $classes, + 'Should not include the abstract base class' + ); + } + + // ────────────────────────────────────────────────────────────── + // Labels + // ────────────────────────────────────────────────────────────── + + public function test_labels_use_attribute_title_when_available(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + + // Check PolydockAiApp - it may have an attribute (after package update) or fallback format + $aiAppLabel = $classes[\FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class] ?? null; + $this->assertNotNull($aiAppLabel, 'PolydockAiApp should have a label'); + + // The label should either be the attribute title OR the fallback format + $hasAttributeTitle = $aiAppLabel === 'Generic Lagoon AI App'; + $hasFallbackFormat = str_contains($aiAppLabel, 'PolydockAiApp') && str_contains($aiAppLabel, 'FreedomtechHosting'); + + $this->assertTrue( + $hasAttributeTitle || $hasFallbackFormat, + "Label should be either 'Generic Lagoon AI App' (attribute) or contain class/namespace (fallback). Got: {$aiAppLabel}" + ); + } + + public function test_build_label_uses_attribute_when_present(): void + { + // Create a mock class with the PolydockAppTitle attribute for testing + // This tests the buildLabel logic directly using reflection on a known attributed class + $reflection = new \ReflectionClass(\FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class); + $attributes = $reflection->getAttributes(\FreedomtechHosting\PolydockApp\Attributes\PolydockAppTitle::class); + + if (! empty($attributes)) { + // If the attribute exists (after package update), verify it's read correctly + $titleAttr = $attributes[0]->newInstance(); + $this->assertEquals('Generic Lagoon AI App', $titleAttr->title); + } else { + // If attribute doesn't exist yet (before package update), just verify the class exists + $this->assertTrue(class_exists(\FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class)); + } + } + + public function test_keys_are_sorted_alphabetically(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + $keys = array_keys($classes); + $sorted = $keys; + sort($sorted); + + $this->assertSame($sorted, $keys, 'Class keys should be sorted alphabetically'); + } + + // ────────────────────────────────────────────────────────────── + // Caching + // ────────────────────────────────────────────────────────────── + + public function test_caching_returns_identical_result(): void + { + $first = $this->discovery->getAvailableAppClasses(); + $second = $this->discovery->getAvailableAppClasses(); + + $this->assertSame($first, $second, 'Cached results should be identical'); + } + + public function test_clear_cache_allows_rediscovery(): void + { + $first = $this->discovery->getAvailableAppClasses(); + $this->discovery->clearCache(); + $second = $this->discovery->getAvailableAppClasses(); + + $this->assertEquals($first, $second, 'Rediscovered results should match'); + } + + // ────────────────────────────────────────────────────────────── + // isValidAppClass() — positive cases + // ────────────────────────────────────────────────────────────── + + public function test_is_valid_app_class_returns_true_for_known_class(): void + { + $this->assertTrue( + $this->discovery->isValidAppClass( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ) + ); + } + + public function test_is_valid_app_class_returns_true_for_all_discovered_classes(): void + { + foreach (array_keys($this->discovery->getAvailableAppClasses()) as $className) { + $this->assertTrue( + $this->discovery->isValidAppClass($className), + "{$className} should be valid" + ); + } + } + + // ────────────────────────────────────────────────────────────── + // isValidAppClass() — negative / edge cases + // ────────────────────────────────────────────────────────────── + + public function test_is_valid_app_class_returns_false_for_nonexistent_class(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass('Acme\\Nonexistent\\FakePolydockApp') + ); + } + + public function test_is_valid_app_class_returns_false_for_interface(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass(PolydockAppInterface::class) + ); + } + + public function test_is_valid_app_class_returns_false_for_abstract_class(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass(PolydockAppBase::class) + ); + } + + public function test_is_valid_app_class_returns_false_for_empty_string(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass('') + ); + } + + public function test_is_valid_app_class_returns_false_for_unrelated_class(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass(\stdClass::class) + ); + } + + // ────────────────────────────────────────────────────────────── + // Container binding + // ────────────────────────────────────────────────────────────── + + public function test_service_is_registered_as_singleton(): void + { + $first = app(PolydockAppClassDiscovery::class); + $second = app(PolydockAppClassDiscovery::class); + + $this->assertSame($first, $second, 'Service should be the same singleton instance'); + } + + // ────────────────────────────────────────────────────────────── + // Store App Form Schema + // ────────────────────────────────────────────────────────────── + + public function test_get_store_app_form_schema_returns_empty_for_class_without_attribute(): void + { + $schema = $this->discovery->getStoreAppFormSchema( + \Amazeeio\PolydockAppAmazeeioPrivateGpt\PolydockPrivateGptApp::class + ); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_form_schema_returns_empty_for_invalid_class(): void + { + $schema = $this->discovery->getStoreAppFormSchema('NonExistent\\Class'); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_form_schema_returns_empty_for_empty_string(): void + { + $schema = $this->discovery->getStoreAppFormSchema(''); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_infolist_schema_returns_empty_for_invalid_class(): void + { + $schema = $this->discovery->getStoreAppInfolistSchema('NonExistent\\Class'); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_infolist_schema_returns_empty_for_empty_string(): void + { + $schema = $this->discovery->getStoreAppInfolistSchema(''); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_form_field_names_returns_array(): void + { + $fieldNames = $this->discovery->getStoreAppFormFieldNames( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ); + + $this->assertIsArray($fieldNames); + } + + public function test_get_field_encryption_map_returns_array(): void + { + $schema = $this->discovery->getStoreAppFormSchema( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ); + + $map = $this->discovery->getFieldEncryptionMap($schema); + + $this->assertIsArray($map); + } + + public function test_field_names_are_prefixed_with_app_config(): void + { + $fieldNames = $this->discovery->getStoreAppFormFieldNames( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ); + + $this->assertIsArray($fieldNames); + + // If the class has fields (after package update), they should all be prefixed + foreach ($fieldNames as $fieldName) { + $this->assertStringStartsWith( + 'app_config_', + $fieldName, + "Field '{$fieldName}' should be prefixed with 'app_config_'" + ); + } + } + + public function test_store_app_form_schema_returns_components_when_attribute_present(): void + { + $schema = $this->discovery->getStoreAppFormSchema( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ); + + // The schema may be empty if the package hasn't been updated yet + // But if it has components, they should be Filament components + $this->assertIsArray($schema); + + if (! empty($schema)) { + foreach ($schema as $component) { + $this->assertIsObject($component); + } + } + } +} From 8b641d700eb164b252bad0eefff76a4d129cfa18 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 17 Feb 2026 12:02:28 +0100 Subject: [PATCH 04/13] fix: exception logging --- .../Traits/PolydockEngineFunctionCallerTrait.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php b/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php index 7bff081..20b17c7 100644 --- a/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php +++ b/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php @@ -102,7 +102,7 @@ protected function processPolydockAppUsingFunction( } } catch (Exception $e) { $message = $appFunctionName.' failed - unknown exception'; - $context = $outputContext + ['exception' => $e]; + $context = $outputContext + ['exception' => $e->getMessage(), 'exception_class' => get_class($e)]; $polydockApp->error($message, $context); $appInstance->logLine('error', $message, $context)->setStatus($failedStatus)->save(); } @@ -178,11 +178,11 @@ protected function processPolydockAppPollUpdateUsingFunction( return false; } catch (PolydockEngineProcessPolydockAppInstanceException $e) { - $polydockApp->error($appFunctionName.' failed - process exception', $outputContext + ['exception' => $e]); + $polydockApp->error($appFunctionName.' failed - process exception', $outputContext + ['exception' => $e->getMessage(), 'exception_class' => get_class($e)]); return false; } catch (Exception $e) { - $polydockApp->error($appFunctionName.' failed - unknown exception', $outputContext + ['exception' => $e]); + $polydockApp->error($appFunctionName.' failed - unknown exception', $outputContext + ['exception' => $e->getMessage(), 'exception_class' => get_class($e)]); return false; } From d26ee82889b0baaac5c5bbc0766d1d67cc1833d3 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 17 Feb 2026 19:13:39 +0100 Subject: [PATCH 05/13] chore: fixup env vars --- .lagoon.env | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.lagoon.env b/.lagoon.env index f478270..25abbf2 100644 --- a/.lagoon.env +++ b/.lagoon.env @@ -37,18 +37,16 @@ QUEUE_CONNECTION=redis # No environment vars # Freedom Tech PHP Lagoon Client Settings -FTLAGOON_PRIVATE_KEY_FILE=storage/ftlagoon/.ssh/id_rsa -FTLAGOON_TOKEN_CACHE_DIR=storage/ftlagoon/.tokencache/ +FTLAGOON_PRIVATE_KEY_FILE=/app/storage/ftlagoon/.ssh/polydock +FTLAGOON_TOKEN_CACHE_DIR=/app/storage/ftlagoon/.tokencache/ -FTLAGOON_ENDPOINT=https://api.main.lagoon-core.test6.amazee.io/graphql -FTLAGOON_SSH_USER=lagoon -FTLAGOON_SSH_PORT=22 -FTLAGOON_SSH_SERVER=ssh.main.lagoon-core.test6.amazee.io -FTLAGOON_SSH_PRIVATE_KEY_FILE=/home/bryan/.ssh/id_rsa +# FTLAGOON_ENDPOINT=https://api.main.lagoon-core.test6.amazee.io/graphql +# FTLAGOON_SSH_USER=lagoon +# FTLAGOON_SSH_PORT=22 +# FTLAGOON_SSH_SERVER=ssh.main.lagoon-core.test6.amazee.io +# FTLAGOON_PRIVATE_KEY_FILE=storage/ftlagoon/.ssh/polydock # FTLAGOON_ENDPOINT=https://api.lagoon.amazeeio.cloud/graphql # FTLAGOON_SSH_USER=lagoon # FTLAGOON_SSH_PORT=32222 # FTLAGOON_SSH_SERVER=ssh.lagoon.amazeeio.cloud -# FTLAGOON_SSH_PRIVATE_KEY_FILE=/home/bryan/.ssh/id_rsa - From a0e0a90a84d5f93b083e57f3c13eb35f07588a53 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 17 Feb 2026 22:56:54 +0100 Subject: [PATCH 06/13] chore: require amazeeio/lagoon-logs --- composer.json | 1 + composer.lock | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 47b1130..cfc78f7 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.3", "ext-curl": "*", + "amazeeio/lagoon-logs": "^0.0.5", "amazeeio/polydock-app-amazeeio-privategpt": "^0.0.11", "evanschleret/lara-mjml": "^0.3.0", "filament/filament": "^3.2", diff --git a/composer.lock b/composer.lock index 2cebc25..6818511 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,55 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "227105b87cebeb9d8e33b0ad823e68d3", + "content-hash": "e310386e38a65e83d5071a758d2bc500", "packages": [ + { + "name": "amazeeio/lagoon-logs", + "version": "v0.0.5", + "source": { + "type": "git", + "url": "https://github.com/amazeeio/laravel_lagoon_logs.git", + "reference": "2d25153b36f1de3c4c8ae45c167319c5164f3d83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amazeeio/laravel_lagoon_logs/zipball/2d25153b36f1de3c4c8ae45c167319c5164f3d83", + "reference": "2d25153b36f1de3c4c8ae45c167319c5164f3d83", + "shasum": "" + }, + "require": { + "monolog/monolog": "^2.0|^3.0" + }, + "type": "package", + "extra": { + "laravel": { + "providers": [ + "amazeeio\\LagoonLogs\\LagoonLogsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "amazeeio\\LagoonLogs\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Blaize Kaye", + "email": "blaize.kaye@amazee.com" + } + ], + "description": "Zero config logging module for Laravel and Lagoon", + "support": { + "issues": "https://github.com/amazeeio/laravel_lagoon_logs/issues", + "source": "https://github.com/amazeeio/laravel_lagoon_logs/tree/v0.0.5" + }, + "time": "2023-04-19T05:21:35+00:00" + }, { "name": "amazeeio/polydock-app-amazeeio-privategpt", "version": "v0.0.11", From c131fc443fd7ea110ad860a2b819e78a5ba8a0de Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 17 Feb 2026 23:32:42 +0100 Subject: [PATCH 07/13] fix: additional null check return string, and ensure polydock_store_id is set for create webhook store --- app/Filament/Admin/Resources/PolydockStoreAppResource.php | 3 ++- .../Admin/Resources/PolydockStoreWebhookResource.php | 5 +++++ app/Models/PolydockStoreApp.php | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource.php b/app/Filament/Admin/Resources/PolydockStoreAppResource.php index 4db8de8..a575c60 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource.php @@ -269,7 +269,8 @@ public static function infolist(Infolist $infolist): Infolist ]), \Filament\Infolists\Components\TextEntry::make('description') ->markdown() - ->columnSpanFull(), + ->columnSpanFull() + ->hidden(fn ($record) => blank($record->description)), \Filament\Infolists\Components\Grid::make(3) ->schema([ diff --git a/app/Filament/Admin/Resources/PolydockStoreWebhookResource.php b/app/Filament/Admin/Resources/PolydockStoreWebhookResource.php index 3274fe2..82cd258 100644 --- a/app/Filament/Admin/Resources/PolydockStoreWebhookResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreWebhookResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Admin\Resources; use App\Filament\Admin\Resources\PolydockStoreWebhookResource\Pages; +use App\Models\PolydockStore; use App\Models\PolydockStoreWebhook; use Filament\Forms; use Filament\Forms\Form; @@ -29,6 +30,10 @@ public static function form(Form $form): Form { return $form ->schema([ + Forms\Components\Select::make('polydock_store_id') + ->label('Store') + ->options(PolydockStore::all()->pluck('name', 'id')) + ->required(), Forms\Components\TextInput::make('url') ->required() ->maxLength(255) diff --git a/app/Models/PolydockStoreApp.php b/app/Models/PolydockStoreApp.php index 026b7a8..67e0f8b 100644 --- a/app/Models/PolydockStoreApp.php +++ b/app/Models/PolydockStoreApp.php @@ -164,7 +164,7 @@ public function getLagoonDeployOrganizationIdExtAttribute(): string /** * Get the Amazee AI backend region ID attribute */ - public function getAmazeeAiBackendRegionIdExtAttribute(): string + public function getAmazeeAiBackendRegionIdExtAttribute(): ?string { return $this->store->amazee_ai_backend_region_id_ext; } From 4bd9fe40c55083d65453748f42c7c671f5cf1574 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 18 Feb 2026 15:25:13 +0100 Subject: [PATCH 08/13] chore: add lagoon_deploy_group_name to create store app page --- .../Admin/Resources/PolydockStoreResource.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/Filament/Admin/Resources/PolydockStoreResource.php b/app/Filament/Admin/Resources/PolydockStoreResource.php index fbe8107..8e73a19 100644 --- a/app/Filament/Admin/Resources/PolydockStoreResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreResource.php @@ -79,6 +79,16 @@ public static function form(Form $form): Form ->disabled( fn (?PolydockStore $record) => $record && $record->apps()->whereHas('instances')->exists(), ), + Forms\Components\TextInput::make('lagoon_deploy_group_name') + ->label('Lagoon Deploy Group Name') + ->required() + ->maxLength(255) + ->dehydrated( + fn (?PolydockStore $record) => ! $record || ! $record->apps()->whereHas('instances')->exists(), + ) + ->disabled( + fn (?PolydockStore $record) => $record && $record->apps()->whereHas('instances')->exists(), + ), Forms\Components\Textarea::make('lagoon_deploy_private_key') ->label('Lagoon Deploy Private Key') ->columnSpanFull() @@ -142,6 +152,9 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('lagoon_deploy_organization_id_ext') ->label('Deploy Org') ->searchable(), + Tables\Columns\TextColumn::make('lagoon_deploy_group_name') + ->label('Deploy Group') + ->searchable(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() @@ -248,6 +261,13 @@ public static function infolist(Infolist $infolist): Infolist ->icon('heroicon-m-building-office') ->iconColor('warning'), ]), + \Filament\Infolists\Components\Grid::make(2) + ->schema([ + \Filament\Infolists\Components\TextEntry::make('lagoon_deploy_group_name') + ->label('Deploy Group Name') + ->icon('heroicon-m-users') + ->iconColor('primary'), + ]), ]) ->columnSpan(1), ]) From 35abc96807269f83c18859df5ed3812495082359 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 18 Feb 2026 21:05:26 +0100 Subject: [PATCH 09/13] chore: output exception messages --- .../PolydockEngineFunctionCallerTrait.php | 73 ++++++++++++++----- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php b/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php index 20b17c7..81f0b38 100644 --- a/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php +++ b/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php @@ -58,10 +58,13 @@ protected function processPolydockAppUsingFunction( return false; } } catch (Exception $e) { - $this->error( - $appFunctionName.' failed - unknown initialisation exception', - $outputContext + ['exception' => $e], - ); + $message = $appFunctionName.' failed - unknown initialisation exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); return false; } @@ -84,7 +87,11 @@ protected function processPolydockAppUsingFunction( return true; } catch (PolydockAppInstanceStatusFlowException $e) { $message = $appFunctionName.' failed - status flow exception'; - $context = $outputContext + ['exception' => $e->getMessage()]; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + ]; + $this->error($message, $context); $polydockApp->error($message, $context); if ($appInstance->getStatus() !== $failedStatus) { $polydockApp->info('Forcing status to '.$failedStatus->value, $outputContext); @@ -94,15 +101,27 @@ protected function processPolydockAppUsingFunction( return false; } catch (PolydockEngineProcessPolydockAppInstanceException $e) { $message = $appFunctionName.' failed - process exception'; - $context = $outputContext + ['exception' => $e]; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); $polydockApp->error($message, $context); if ($appInstance->getStatus() !== $failedStatus) { $polydockApp->info('Forcing status to '.$failedStatus->value, $outputContext); $appInstance->logLine('error', $message, $context)->setStatus($failedStatus)->save(); } + + return false; } catch (Exception $e) { $message = $appFunctionName.' failed - unknown exception'; - $context = $outputContext + ['exception' => $e->getMessage(), 'exception_class' => get_class($e)]; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); $polydockApp->error($message, $context); $appInstance->logLine('error', $message, $context)->setStatus($failedStatus)->save(); } @@ -139,10 +158,13 @@ protected function processPolydockAppPollUpdateUsingFunction( return false; } } catch (Exception $e) { - $this->error( - $appFunctionName.' failed - unknown initialisation exception', - $outputContext + ['exception' => $e], - ); + $message = $appFunctionName.' failed - unknown initialisation exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); return false; } @@ -171,18 +193,35 @@ protected function processPolydockAppPollUpdateUsingFunction( return true; } catch (PolydockAppInstanceStatusFlowException $e) { - $polydockApp->error( - $appFunctionName.' failed - status flow exception', - $outputContext + ['exception' => $e], - ); + $message = $appFunctionName.' failed - status flow exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + ]; + $this->error($message, $context); + $polydockApp->error($message, $context); return false; } catch (PolydockEngineProcessPolydockAppInstanceException $e) { - $polydockApp->error($appFunctionName.' failed - process exception', $outputContext + ['exception' => $e->getMessage(), 'exception_class' => get_class($e)]); + $message = $appFunctionName.' failed - process exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); + $polydockApp->error($message, $context); return false; } catch (Exception $e) { - $polydockApp->error($appFunctionName.' failed - unknown exception', $outputContext + ['exception' => $e->getMessage(), 'exception_class' => get_class($e)]); + $message = $appFunctionName.' failed - unknown exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); + $polydockApp->error($message, $context); return false; } From c66b22c82853aeffc5f40656e27a38446184f865 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Thu, 19 Feb 2026 12:05:06 +0100 Subject: [PATCH 10/13] Update app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Pages/CreatePolydockAppInstance.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php index bf8312e..dc918c8 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php @@ -169,8 +169,13 @@ public function create(): void { $data = $this->form->getState(); - Log::info('Admin creating app instance', ['data' => $data]); + $safeLogData = [ + 'email' => $data['email'] ?? null, + 'trial_app' => $data['trial_app'] ?? null, + 'is_trial' => $data['is_trial'] ?? null, + ]; + Log::info('Admin creating app instance', $safeLogData); try { // Build the request data array $requestData = [ From efdba867604c3d42c153daed64e38e1225399c02 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Thu, 19 Feb 2026 12:41:22 +0100 Subject: [PATCH 11/13] chore: reset app-specific fields on change of app class --- .../Admin/Resources/PolydockStoreAppResource.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource.php b/app/Filament/Admin/Resources/PolydockStoreAppResource.php index a575c60..7386486 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource.php @@ -47,7 +47,15 @@ public static function form(Form $form): Form ->required() ->searchable() ->live(onBlur: false) - ->afterStateUpdated(fn (Set $set) => null) + ->afterStateUpdated(function (Set $set, ?string $old) { + if ($old) { + $fieldNames = app(PolydockAppClassDiscovery::class) + ->getStoreAppFormFieldNames($old); + foreach ($fieldNames as $fieldName) { + $set($fieldName, null); + } + } + }) ->helperText('The application class that controls deployment and lifecycle behaviour.') ->disabled(fn (?PolydockStoreApp $record) => $record && $record->instances()->exists()) ->dehydrated(fn (?PolydockStoreApp $record) => ! $record || ! $record->instances()->exists()), From f28bd5d546a5e02eac9e2120bb8356671ff11c92 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Thu, 19 Feb 2026 13:11:35 +0100 Subject: [PATCH 12/13] chore: fixes and run pint --- .../Resources/PolydockAppInstanceResource.php | 2 +- .../Pages/ViewPolydockStoreApp.php | 2 + .../Pages/ViewUserRemoteRegistration.php | 2 +- app/Jobs/ProcessUserRemoteRegistration.php | 3 +- app/Models/PolydockAppInstance.php | 8 ++++ .../PolydockEngineFunctionCallerTrait.php | 16 ++++---- ...PolydockServiceProviderAmazeeAiBackend.php | 32 ++++++++++++++++ .../PolydockServiceProviderFTLagoon.php | 38 ++++++++++++++++++- 8 files changed, 90 insertions(+), 13 deletions(-) diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php index d974e66..40970c5 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php @@ -276,7 +276,7 @@ public static function getRenderedSafeDataForRecord(PolydockAppInstance $record) } if ($value === null) { - $value = ''; + $value = 'N/A'; } $renderKey = 'webhook_data_'.$key; diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php index a1983fb..e4be5f3 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php @@ -1,5 +1,7 @@ registration->request_data ?? []; diff --git a/app/Models/PolydockAppInstance.php b/app/Models/PolydockAppInstance.php index 39691d9..161b4ae 100644 --- a/app/Models/PolydockAppInstance.php +++ b/app/Models/PolydockAppInstance.php @@ -84,6 +84,11 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface */ private PolydockAppLoggerInterface $logger; + /** + * The name of the app instance + */ + private string $name; + // Add default sensitive keys specific to app instances protected array $sensitiveDataKeys = [ // Exact matches @@ -394,6 +399,9 @@ public function setName(string $name): self return $this; } + /** + * Get the name of the app instance + */ public function getName(): string { return $this->name; diff --git a/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php b/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php index 81f0b38..0a6562c 100644 --- a/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php +++ b/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php @@ -61,7 +61,7 @@ protected function processPolydockAppUsingFunction( $message = $appFunctionName.' failed - unknown initialisation exception'; $context = $outputContext + [ 'exception_message' => $e->getMessage(), - 'exception_class' => get_class($e), + 'exception_class' => $e::class, 'exception_trace' => $e->getTraceAsString(), ]; $this->error($message, $context); @@ -89,7 +89,7 @@ protected function processPolydockAppUsingFunction( $message = $appFunctionName.' failed - status flow exception'; $context = $outputContext + [ 'exception_message' => $e->getMessage(), - 'exception_class' => get_class($e), + 'exception_class' => $e::class, ]; $this->error($message, $context); $polydockApp->error($message, $context); @@ -103,7 +103,7 @@ protected function processPolydockAppUsingFunction( $message = $appFunctionName.' failed - process exception'; $context = $outputContext + [ 'exception_message' => $e->getMessage(), - 'exception_class' => get_class($e), + 'exception_class' => $e::class, 'exception_trace' => $e->getTraceAsString(), ]; $this->error($message, $context); @@ -118,7 +118,7 @@ protected function processPolydockAppUsingFunction( $message = $appFunctionName.' failed - unknown exception'; $context = $outputContext + [ 'exception_message' => $e->getMessage(), - 'exception_class' => get_class($e), + 'exception_class' => $e::class, 'exception_trace' => $e->getTraceAsString(), ]; $this->error($message, $context); @@ -161,7 +161,7 @@ protected function processPolydockAppPollUpdateUsingFunction( $message = $appFunctionName.' failed - unknown initialisation exception'; $context = $outputContext + [ 'exception_message' => $e->getMessage(), - 'exception_class' => get_class($e), + 'exception_class' => $e::class, 'exception_trace' => $e->getTraceAsString(), ]; $this->error($message, $context); @@ -196,7 +196,7 @@ protected function processPolydockAppPollUpdateUsingFunction( $message = $appFunctionName.' failed - status flow exception'; $context = $outputContext + [ 'exception_message' => $e->getMessage(), - 'exception_class' => get_class($e), + 'exception_class' => $e::class, ]; $this->error($message, $context); $polydockApp->error($message, $context); @@ -206,7 +206,7 @@ protected function processPolydockAppPollUpdateUsingFunction( $message = $appFunctionName.' failed - process exception'; $context = $outputContext + [ 'exception_message' => $e->getMessage(), - 'exception_class' => get_class($e), + 'exception_class' => $e::class, 'exception_trace' => $e->getTraceAsString(), ]; $this->error($message, $context); @@ -217,7 +217,7 @@ protected function processPolydockAppPollUpdateUsingFunction( $message = $appFunctionName.' failed - unknown exception'; $context = $outputContext + [ 'exception_message' => $e->getMessage(), - 'exception_class' => get_class($e), + 'exception_class' => $e::class, 'exception_trace' => $e->getTraceAsString(), ]; $this->error($message, $context); diff --git a/app/PolydockServiceProviders/PolydockServiceProviderAmazeeAiBackend.php b/app/PolydockServiceProviders/PolydockServiceProviderAmazeeAiBackend.php index 75b1d5b..aa61dcb 100644 --- a/app/PolydockServiceProviders/PolydockServiceProviderAmazeeAiBackend.php +++ b/app/PolydockServiceProviders/PolydockServiceProviderAmazeeAiBackend.php @@ -16,6 +16,11 @@ class PolydockServiceProviderAmazeeAiBackend implements PolydockServiceProviderI protected Client $AmazeeAiBackendClient; + /** + * Construct a new service provider amazee.ai backend. + * + * @throws PolydockEngineServiceProviderInitializationException + */ public function __construct(array $config, PolydockAppLoggerInterface $logger) { $this->setLogger($logger); @@ -86,26 +91,41 @@ protected function initAmazeeAiBackendClient(array $config) } } + /** + * Get the amazee.ai backend client. + */ public function getAmazeeAiBackendClient(): Client { return $this->AmazeeAiBackendClient; } + /** + * Fixed name for this provider. + */ public function getName(): string { return 'Polydock-Amazee-AI-Backend-Client-Provider'; } + /** + * Fixed description for this provider. + */ public function getDescription(): string { return 'An implementation of the Polydock Amazee AI Backend Client from polydock-amazeeai-backend-client-php'; } + /** + * Get the logger instance. + */ public function getLogger(): PolydockAppLoggerInterface { return $this->logger; } + /** + * Set the logger instance. Return self for chaining. + */ public function setLogger(PolydockAppLoggerInterface $logger): self { $this->logger = $logger; @@ -113,6 +133,9 @@ public function setLogger(PolydockAppLoggerInterface $logger): self return $this; } + /** + * Send a message marked as info level to the logger. + */ public function info(string $message, array $context = []): self { $this->logger->info($message, $context); @@ -120,6 +143,9 @@ public function info(string $message, array $context = []): self return $this; } + /** + * Send a message marked as error level to the logger. + */ public function error(string $message, array $context = []): self { $this->logger->error($message, $context); @@ -127,6 +153,9 @@ public function error(string $message, array $context = []): self return $this; } + /** + * Send a message marked as warning level to the logger. + */ public function warning(string $message, array $context = []): self { $this->logger->warning($message, $context); @@ -134,6 +163,9 @@ public function warning(string $message, array $context = []): self return $this; } + /** + * Send a message marked as debug level to the logger. + */ public function debug(string $message, array $context = []): self { $this->logger->debug($message, $context); diff --git a/app/PolydockServiceProviders/PolydockServiceProviderFTLagoon.php b/app/PolydockServiceProviders/PolydockServiceProviderFTLagoon.php index 4c82006..5ef20c9 100644 --- a/app/PolydockServiceProviders/PolydockServiceProviderFTLagoon.php +++ b/app/PolydockServiceProviders/PolydockServiceProviderFTLagoon.php @@ -16,8 +16,12 @@ class PolydockServiceProviderFTLagoon implements PolydockServiceProviderInterfac protected Client $LagoonClient; - /** @var int Maximum age in minutes before a token is considered expired */ - const MAX_TOKEN_AGE_MINUTES = 2; + /** + * Maximum age in minutes before a token is considered expired. + * + * @var int + */ + private const int MAX_TOKEN_AGE_MINUTES = 2; public function __construct(array $config, PolydockAppLoggerInterface $logger) { @@ -106,31 +110,49 @@ protected function initLagoonClient(array $config) } } + /** + * Return the lagoon client. + */ public function getLagoonClient(): Client { return $this->LagoonClient; } + /** + * Return the max token age in minutes. + */ public function getMaxTokenAgeMinutes(): int { return self::MAX_TOKEN_AGE_MINUTES; } + /** + * Fixed name for this provider. + */ public function getName(): string { return 'FT-Lagoon-Client-Provider'; } + /** + * Fixed description of this provider. + */ public function getDescription(): string { return 'An implementation of the FT Lagoon Client from ft-lagoon-php'; } + /** + * Get the logger instance. + */ public function getLogger(): PolydockAppLoggerInterface { return $this->logger; } + /** + * Set the logger instance. Return self for chaining. + */ public function setLogger(PolydockAppLoggerInterface $logger): self { $this->logger = $logger; @@ -138,6 +160,9 @@ public function setLogger(PolydockAppLoggerInterface $logger): self return $this; } + /** + * Send a message marked as info level to the logger. + */ public function info(string $message, array $context = []): self { $this->logger->info($message, $context); @@ -145,6 +170,9 @@ public function info(string $message, array $context = []): self return $this; } + /** + * Send a message marked as error level to the logger. + */ public function error(string $message, array $context = []): self { $this->logger->error($message, $context); @@ -152,6 +180,9 @@ public function error(string $message, array $context = []): self return $this; } + /** + * Send a message marked as warning level to the logger. + */ public function warning(string $message, array $context = []): self { $this->logger->warning($message, $context); @@ -159,6 +190,9 @@ public function warning(string $message, array $context = []): self return $this; } + /** + * Send a message marked as debug level to the logger. + */ public function debug(string $message, array $context = []): self { $this->logger->debug($message, $context); From 96b829ad3cf4bb72b3e0e8a6a8e5d8ee53ac2bce Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Thu, 19 Feb 2026 14:34:47 +0100 Subject: [PATCH 13/13] chore: use livewire tests --- .../CreatePolydockAppInstanceTest.php | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/tests/Feature/Filament/CreatePolydockAppInstanceTest.php b/tests/Feature/Filament/CreatePolydockAppInstanceTest.php index f93630a..d267ff4 100644 --- a/tests/Feature/Filament/CreatePolydockAppInstanceTest.php +++ b/tests/Feature/Filament/CreatePolydockAppInstanceTest.php @@ -10,6 +10,7 @@ use App\Models\User; use App\Models\UserRemoteRegistration; use Filament\Facades\Filament; +use Livewire\Livewire; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -50,40 +51,21 @@ public function test_admin_can_create_app_instance(): void { Queue::fake(); - // Directly call the create method on the page instance - $page = new CreatePolydockAppInstance; - $page->data = [ - 'email' => 'newuser@example.com', - 'first_name' => 'John', - 'last_name' => 'Doe', - 'organization' => 'Test Org', - 'trial_app' => $this->storeApp->uuid, - 'is_trial' => true, - 'aup_and_privacy_acceptance' => true, - 'opt_in_to_product_updates' => true, - ]; - - // Mock form validation $this->actingAs($this->admin); - // Create registration directly as the form would - $registration = UserRemoteRegistration::create([ - 'email' => 'newuser@example.com', - 'request_data' => [ + Livewire::test(CreatePolydockAppInstance::class) + ->fillForm([ 'email' => 'newuser@example.com', 'first_name' => 'John', 'last_name' => 'Doe', 'organization' => 'Test Org', - 'register_type' => 'REQUEST_TRIAL', 'trial_app' => $this->storeApp->uuid, - 'aup_and_privacy_acceptance' => 1, - 'opt_in_to_product_updates' => 1, - 'admin_created' => true, 'is_trial' => true, - ], - ]); - - ProcessUserRemoteRegistration::dispatch($registration); + 'aup_and_privacy_acceptance' => true, + 'opt_in_to_product_updates' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); // Verify registration was created $this->assertDatabaseHas('user_remote_registrations', [ @@ -94,6 +76,26 @@ public function test_admin_can_create_app_instance(): void Queue::assertPushed(ProcessUserRemoteRegistration::class); } + public function test_create_form_validates_required_fields(): void + { + $this->actingAs($this->admin); + + Livewire::test(CreatePolydockAppInstance::class) + ->fillForm([ + 'email' => '', + 'first_name' => '', + 'last_name' => '', + 'trial_app' => null, + ]) + ->call('create') + ->assertHasFormErrors([ + 'email' => 'required', + 'first_name' => 'required', + 'last_name' => 'required', + 'trial_app' => 'required', + ]); + } + public function test_registration_includes_admin_created_flag(): void { Queue::fake();