diff --git a/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php b/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php index b599f49795..8a127f3a5c 100644 --- a/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php +++ b/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php @@ -104,10 +104,15 @@ public function index(Request $request) */ public function store(Request $request) { - $request->validate(EnvironmentVariable::rules(), EnvironmentVariable::messages()); - $environment_variable = EnvironmentVariable::create($request->all()); + $data = $this->prepareAssetLinkInput($request->all()); + validator($data, EnvironmentVariable::rules(), EnvironmentVariable::messages())->validate(); + $linkedAsset = EnvironmentVariable::validateAssetLinkConsistency($data); + if ($linkedAsset) { + $data['value'] = (string) $linkedAsset->id; + } + $environment_variable = EnvironmentVariable::create($data); // Register the Event - EnvironmentVariablesCreated::dispatch($request->all()); + EnvironmentVariablesCreated::dispatch($data); return new EnvironmentVariableResource($environment_variable); } @@ -172,14 +177,22 @@ public function show(EnvironmentVariable $environment_variable) */ public function update(EnvironmentVariable $environment_variable, Request $request) { - $fields = ['name', 'description']; - if ($request->filled('value')) { + $data = $this->prepareAssetLinkInput($request->all()); + // Validate the request, passing in the existing variable to tweak unique rule on name + validator($data, EnvironmentVariable::rules($environment_variable), EnvironmentVariable::messages())->validate(); + $linkedAsset = EnvironmentVariable::validateAssetLinkConsistency($data); + + $fields = ['name', 'description', 'asset_type']; + if ($linkedAsset) { + // Guarantee value is always the selected asset's ID on this instance. + $data['value'] = (string) $linkedAsset->id; + $fields[] = 'value'; + } elseif (array_key_exists('value', $data) && $data['value'] !== null && $data['value'] !== '') { $fields[] = 'value'; } - // Validate the request, passing in the existing variable to tweak unique rule on name - $request->validate(EnvironmentVariable::rules($environment_variable)); + $original = $environment_variable->getOriginal(); - $environment_variable->fill($request->only($fields)); + $environment_variable->fill(collect($data)->only($fields)->all()); $environment_variable->save(); $changes = $environment_variable->getChanges(); @@ -190,6 +203,18 @@ public function update(EnvironmentVariable $environment_variable, Request $reque return new EnvironmentVariableResource($environment_variable); } + /** + * Normalize empty asset_type to null. + */ + private function prepareAssetLinkInput(array $data): array + { + if (array_key_exists('asset_type', $data) && $data['asset_type'] === '') { + $data['asset_type'] = null; + } + + return $data; + } + /** * @OA\Delete( * path="/environment_variables/{environment_variable_id}", diff --git a/ProcessMaker/ImportExport/DependentType.php b/ProcessMaker/ImportExport/DependentType.php index acd2db4e9b..f5000fe6a9 100644 --- a/ProcessMaker/ImportExport/DependentType.php +++ b/ProcessMaker/ImportExport/DependentType.php @@ -22,6 +22,8 @@ abstract class DependentType const ENVIRONMENT_VARIABLE_VALUE = 'environment_variables_value'; + const ENVIRONMENT_VARIABLE_ASSET = 'environment_variable_asset'; + const SCRIPT_EXECUTORS = 'script_executors'; const SUB_PROCESSES = 'sub_processes'; diff --git a/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php b/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php index 2161c90b46..28e9d5178a 100644 --- a/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php +++ b/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php @@ -2,6 +2,8 @@ namespace ProcessMaker\ImportExport\Exporters; +use Illuminate\Support\Facades\Log; +use ProcessMaker\Enums\ExporterMap; use ProcessMaker\ImportExport\DependentType; class EnvironmentVariableExporter extends ExporterBase @@ -17,10 +19,50 @@ class EnvironmentVariableExporter extends ExporterBase public function export() : void { $this->addReference(DependentType::ENVIRONMENT_VARIABLE_VALUE, $this->model->value); + + if (!$this->model->hasLinkedAsset()) { + return; + } + + $asset = $this->model->resolveLinkedAsset(); + if (!$asset) { + Log::warning('EnvironmentVariableExporter: linked asset not found during export', [ + 'environment_variable' => $this->model->name, + 'asset_type' => $this->model->asset_type, + 'value' => $this->model->value, + ]); + + return; + } + + $exporterClass = ExporterMap::getExporterClassForModel($asset); + if (!$exporterClass) { + Log::warning('EnvironmentVariableExporter: no exporter registered for linked asset', [ + 'environment_variable' => $this->model->name, + 'asset_type' => $this->model->asset_type, + ]); + + return; + } + + $this->addDependent(DependentType::ENVIRONMENT_VARIABLE_ASSET, $asset, $exporterClass); } public function import() : bool { + foreach ($this->getDependents(DependentType::ENVIRONMENT_VARIABLE_ASSET, true) as $dependent) { + $asset = $dependent->model; + if (!$asset) { + continue; + } + + $this->model->asset_type = get_class($asset); + $this->model->value = (string) $asset->id; + + return $this->model->save(); + } + + // Non-linked env vars (or discarded asset dependents): restore exported value. $this->model->value = $this->getReference(DependentType::ENVIRONMENT_VARIABLE_VALUE); return $this->model->save(); diff --git a/ProcessMaker/Models/EnvironmentVariable.php b/ProcessMaker/Models/EnvironmentVariable.php index 8aaea1cf0c..38f6e9342b 100644 --- a/ProcessMaker/Models/EnvironmentVariable.php +++ b/ProcessMaker/Models/EnvironmentVariable.php @@ -3,8 +3,11 @@ namespace ProcessMaker\Models; use Exception; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; +use ProcessMaker\Enums\ExporterMap; use ProcessMaker\Traits\Exportable; /** @@ -13,6 +16,7 @@ * @OA\Property(property="name", type="string"), * @OA\Property(property="description", type="string"), * @OA\Property(property="value", type="string"), + * @OA\Property(property="asset_type", type="string", nullable=true), * ), * @OA\Schema( * schema="EnvironmentVariable", @@ -36,12 +40,22 @@ class EnvironmentVariable extends ProcessMakerModel 'name', 'description', 'value', + 'asset_type', ]; protected $hidden = [ 'value', ]; + protected static function boot() + { + parent::boot(); + + static::saving(function (self $environmentVariable) { + $environmentVariable->normalizeAssetLink(); + }); + } + /** * Store the encrypted version of the variable value here */ @@ -74,21 +88,117 @@ public static function rules($existing = null) { $unique = Rule::unique('environment_variables')->ignore($existing); $validVariableName = '/^[a-zA-Z][a-zA-Z_$0-9]*$/'; + $allowedAssetTypes = self::allowedAssetTypes(); return [ 'description' => 'required', - 'value' => 'nullable', + 'value' => [ + 'nullable', + 'required_with:asset_type', + ], 'name' => ['required', "regex:{$validVariableName}", $unique], + 'asset_type' => [ + 'nullable', + 'string', + Rule::in($allowedAssetTypes), + ], ]; } + /** + * Validate that asset_type + value resolve to an existing asset of that type. + * + * @return Model|null The resolved asset when a link is present + */ + public static function validateAssetLinkConsistency(array $data): ?Model + { + $assetType = $data['asset_type'] ?? null; + $value = $data['value'] ?? null; + + if (!$assetType) { + return null; + } + + if ($value === null || $value === '') { + throw ValidationException::withMessages([ + 'value' => [trans('environmentVariables.validation.value.required_with_asset_type')], + ]); + } + + if (!class_exists($assetType)) { + throw ValidationException::withMessages([ + 'asset_type' => [__('The selected asset type is not available.')], + ]); + } + + if (!in_array($assetType, self::allowedAssetTypes(), true)) { + throw ValidationException::withMessages([ + 'asset_type' => [__('The selected asset type is not allowed.')], + ]); + } + + $asset = $assetType::find($value); + if (!$asset) { + throw ValidationException::withMessages([ + 'value' => [trans('environmentVariables.validation.value.not_found_for_type')], + ]); + } + + // Guard against class hierarchy mismatches (e.g. subclass stored under a parent type). + if (get_class($asset) !== $assetType) { + throw ValidationException::withMessages([ + 'asset_type' => [trans('environmentVariables.validation.asset_type.mismatch')], + ]); + } + + return $asset; + } + public static function messages() { return [ 'name.regex' => trans('environmentVariables.validation.name.invalid_variable_name'), + 'value.required_with' => trans('environmentVariables.validation.value.required_with_asset_type'), ]; } + /** + * Exportable model classes that may be linked to an environment variable. + */ + public static function allowedAssetTypes(): array + { + return collect(ExporterMap::TYPES) + ->map(fn (array $type) => $type[0]) + ->filter(fn (string $class) => class_exists($class)) + ->values() + ->all(); + } + + public function resolveLinkedAsset(): ?Model + { + if (!$this->hasLinkedAsset() || !class_exists($this->asset_type)) { + return null; + } + + if ($this->value === null || $this->value === '') { + return null; + } + + return $this->asset_type::find($this->value); + } + + public function hasLinkedAsset(): bool + { + return !empty($this->asset_type); + } + + protected function normalizeAssetLink(): void + { + if ($this->asset_type === '') { + $this->asset_type = null; + } + } + public static function getMetricsApiEndpoint() { $variable = self::where('name', 'METRICS_API_ENDPOINT')->first(); diff --git a/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php b/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php new file mode 100644 index 0000000000..370df325dd --- /dev/null +++ b/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php @@ -0,0 +1,28 @@ +string('asset_type')->nullable()->after('value'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn(['asset_type']); + }); + } +}; diff --git a/resources/js/processes/environment-variables/assetTypes.js b/resources/js/processes/environment-variables/assetTypes.js new file mode 100644 index 0000000000..e03a0892bf --- /dev/null +++ b/resources/js/processes/environment-variables/assetTypes.js @@ -0,0 +1,54 @@ +/** + * Exportable asset types that can be linked to an environment variable. + * apiPath is the local API listing endpoint used by the asset picker. + */ +export default [ + { + class: "ProcessMaker\\Models\\Process", + label: "Process", + apiPath: "processes", + nameField: "name", + }, + { + class: "ProcessMaker\\Models\\Screen", + label: "Screen", + apiPath: "screens", + nameField: "title", + }, + { + class: "ProcessMaker\\Models\\Script", + label: "Script", + apiPath: "scripts", + nameField: "title", + }, + { + class: "ProcessMaker\\Plugins\\Collections\\Models\\Collection", + label: "Collection", + apiPath: "collections", + nameField: "name", + }, + { + class: "ProcessMaker\\Packages\\Connectors\\DataSources\\Models\\DataSource", + label: "Data Connector", + apiPath: "data_sources", + nameField: "name", + }, + { + class: "ProcessMaker\\Package\\PackageDecisionEngine\\Models\\DecisionTable", + label: "Decision Table", + apiPath: "decision_tables", + nameField: "title", + }, + { + class: "ProcessMaker\\Package\\PackageAi\\Models\\FlowGenie", + label: "FlowGenie", + apiPath: "package-ai/flow_genies", + nameField: "name", + }, + { + class: "ProcessMaker\\Package\\PackagePmBlocks\\Models\\PmBlock", + label: "PM Block", + apiPath: "pm-blocks", + nameField: "name", + }, +]; diff --git a/resources/js/processes/environment-variables/components/AssetLinkFields.vue b/resources/js/processes/environment-variables/components/AssetLinkFields.vue new file mode 100644 index 0000000000..4164373a7c --- /dev/null +++ b/resources/js/processes/environment-variables/components/AssetLinkFields.vue @@ -0,0 +1,233 @@ + + + diff --git a/resources/js/processes/environment-variables/components/CreateEnvironmentVariableModal.vue b/resources/js/processes/environment-variables/components/CreateEnvironmentVariableModal.vue index b8ec2f81df..77b52b5a73 100644 --- a/resources/js/processes/environment-variables/components/CreateEnvironmentVariableModal.vue +++ b/resources/js/processes/environment-variables/components/CreateEnvironmentVariableModal.vue @@ -36,28 +36,21 @@ name="description" > - - - + + @endsection diff --git a/tests/Feature/Api/EnvironmentVariablesTest.php b/tests/Feature/Api/EnvironmentVariablesTest.php index eb6b17f28f..2434a85ea3 100644 --- a/tests/Feature/Api/EnvironmentVariablesTest.php +++ b/tests/Feature/Api/EnvironmentVariablesTest.php @@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Hash; use ProcessMaker\Models\EnvironmentVariable; +use ProcessMaker\Models\Screen; +use ProcessMaker\Models\Script; use ProcessMaker\Models\User; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -255,4 +257,124 @@ public function testCreationOfMetricVariable() 'name' => 'METRICS_API_ENDPOINT', ]); } + + /** @test */ + public function test_it_should_create_an_environment_variable_linked_to_an_asset() + { + $screen = Screen::factory()->create(); + + $response = $this->apiCall('POST', self::API_TEST_VARIABLES, [ + 'name' => 'MY_SCREEN_ID', + 'description' => 'Linked screen id', + 'asset_type' => Screen::class, + 'value' => (string) $screen->id, + ]); + + $response->assertStatus(201); + $response->assertJsonFragment([ + 'name' => 'MY_SCREEN_ID', + 'asset_type' => Screen::class, + ]); + + $variable = EnvironmentVariable::where('name', 'MY_SCREEN_ID')->firstOrFail(); + $this->assertEquals((string) $screen->id, $variable->value); + $this->assertEquals(Screen::class, $variable->asset_type); + } + + /** @test */ + public function test_it_should_reject_invalid_asset_link() + { + $response = $this->apiCall('POST', self::API_TEST_VARIABLES, [ + 'name' => 'MY_SCREEN_ID', + 'description' => 'Linked screen id', + 'asset_type' => Screen::class, + 'value' => '999999', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['value']); + } + + /** @test */ + public function test_it_should_update_environment_variable_asset_link() + { + $screen = Screen::factory()->create(); + $variable = EnvironmentVariable::factory()->create([ + 'name' => 'MY_SCREEN_ID', + 'value' => 'old-value', + ]); + + $response = $this->apiCall('PUT', self::API_TEST_VARIABLES . '/' . $variable->id, [ + 'name' => 'MY_SCREEN_ID', + 'description' => 'Linked screen id', + 'asset_type' => Screen::class, + 'value' => (string) $screen->id, + ]); + + $response->assertStatus(200); + $variable->refresh(); + $this->assertEquals((string) $screen->id, $variable->value); + $this->assertEquals(Screen::class, $variable->asset_type); + } + + /** @test */ + public function test_update_rejects_value_not_found_for_asset_type() + { + $variable = EnvironmentVariable::factory()->create([ + 'name' => 'MY_SCREEN_ID', + 'value' => 'old-value', + ]); + + $response = $this->apiCall('PUT', self::API_TEST_VARIABLES . '/' . $variable->id, [ + 'name' => 'MY_SCREEN_ID', + 'description' => 'Linked screen id', + 'asset_type' => Screen::class, + 'value' => '999999', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['value']); + } + + /** @test */ + public function test_update_accepts_value_matching_selected_asset_id() + { + $screen = Screen::factory()->create(); + $variable = EnvironmentVariable::factory()->create([ + 'name' => 'MY_SCREEN_ID', + 'value' => 'old-value', + ]); + + $response = $this->apiCall('PUT', self::API_TEST_VARIABLES . '/' . $variable->id, [ + 'name' => 'MY_SCREEN_ID', + 'description' => 'Linked screen id', + 'asset_type' => Screen::class, + 'value' => (string) $screen->id, + ]); + + $response->assertStatus(200); + $variable->refresh(); + $this->assertEquals((string) $screen->id, $variable->value); + } + + /** @test */ + public function test_update_rejects_value_id_of_wrong_asset_type() + { + $script = Script::factory()->create(); + $variable = EnvironmentVariable::factory()->create([ + 'name' => 'MY_SCREEN_ID', + 'value' => 'old-value', + ]); + + // Script ID with Screen type — pair is inconsistent. + $response = $this->apiCall('PUT', self::API_TEST_VARIABLES . '/' . $variable->id, [ + 'name' => 'MY_SCREEN_ID', + 'description' => 'Linked screen id', + 'asset_type' => Screen::class, + 'value' => (string) $script->id, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['value']); + } } diff --git a/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php b/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php index 5dcbb6b564..98fcbeef25 100644 --- a/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php +++ b/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php @@ -6,7 +6,9 @@ use Illuminate\Support\Facades\DB; use ProcessMaker\ImportExport\Exporters\ScriptExporter; use ProcessMaker\ImportExport\Options; +use ProcessMaker\ImportExport\DependentType; use ProcessMaker\Models\EnvironmentVariable; +use ProcessMaker\Models\Screen; use ProcessMaker\Models\Script; use ProcessMaker\Models\ScriptCategory; use ProcessMaker\Models\User; @@ -191,4 +193,95 @@ public function testWithDuplicatedEnvVariable() $this->assertDatabaseHas('environment_variables', ['name' => $environmentVariable1->name . '_2']); $this->assertDatabaseHas('environment_variables', ['name' => $environmentVariable2->name . '_2']); $this->assertDatabaseMissing('environment_variables', ['name' => $environmentVariable3->name . '_2']); - }} + } + + public function testLinkedAssetRemappedOnImportUpdateMode() + { + $screen = Screen::factory()->create(['title' => 'Linked Screen']); + $environmentVariable = EnvironmentVariable::factory()->create([ + 'name' => 'MY_SCREEN_ID', + 'description' => 'Screen id for scripts', + 'asset_type' => Screen::class, + 'value' => (string) $screen->id, + ]); + $script = Script::factory()->create([ + 'title' => 'script with linked env var', + 'code' => 'export($script, ScriptExporter::class); + + $envVarPayload = collect($payload['export'])->first(function ($asset) { + return ($asset['model'] ?? null) === EnvironmentVariable::class; + }); + $this->assertNotNull($envVarPayload); + $this->assertTrue( + collect($envVarPayload['dependents'])->pluck('type')->contains(DependentType::ENVIRONMENT_VARIABLE_ASSET) + ); + + // Simulate a stale numeric ID on the target instance before update import. + DB::table('environment_variables') + ->where('id', $environmentVariable->id) + ->update(['value' => encrypt('999999')]); + $this->assertEquals('999999', $environmentVariable->fresh()->value); + + $options = new Options([ + $script->uuid => ['mode' => 'update'], + $environmentVariable->uuid => ['mode' => 'update'], + $screen->uuid => ['mode' => 'update'], + ]); + $this->import($payload, $options); + + $screen->refresh(); + $environmentVariable->refresh(); + + $this->assertEquals(1, Screen::where('title', 'Linked Screen')->count()); + $this->assertEquals(1, EnvironmentVariable::where('name', 'MY_SCREEN_ID')->count()); + $this->assertEquals(Screen::class, $environmentVariable->asset_type); + $this->assertEquals((string) $screen->id, $environmentVariable->value); + } + + public function testLinkedAssetRemappedOnImportCopyMode() + { + $screen = Screen::factory()->create(['title' => 'Linked Screen']); + $environmentVariable = EnvironmentVariable::factory()->create([ + 'name' => 'MY_SCREEN_ID', + 'description' => 'Screen id for scripts', + 'asset_type' => Screen::class, + 'value' => (string) $screen->id, + ]); + $script = Script::factory()->create([ + 'title' => 'script with linked env var', + 'code' => 'uuid; + $originalScreenId = $screen->id; + $originalEnvUuid = $environmentVariable->uuid; + + $payload = $this->export($script, ScriptExporter::class); + + $options = new Options([ + $script->uuid => ['mode' => 'copy'], + $environmentVariable->uuid => ['mode' => 'copy'], + $screen->uuid => ['mode' => 'copy'], + ]); + $this->import($payload, $options); + + // Originals remain unchanged. + $screen->refresh(); + $environmentVariable->refresh(); + $this->assertEquals($originalScreenUuid, $screen->uuid); + $this->assertEquals((string) $originalScreenId, $environmentVariable->value); + $this->assertEquals(Screen::class, $environmentVariable->asset_type); + $this->assertEquals($originalEnvUuid, $environmentVariable->uuid); + + $copiedScreen = Screen::where('title', 'Linked Screen 2')->firstOrFail(); + $copiedVariable = EnvironmentVariable::where('name', 'MY_SCREEN_ID_2')->firstOrFail(); + + $this->assertNotEquals($originalScreenUuid, $copiedScreen->uuid); + $this->assertNotEquals($originalEnvUuid, $copiedVariable->uuid); + $this->assertEquals(Screen::class, $copiedVariable->asset_type); + $this->assertEquals((string) $copiedScreen->id, $copiedVariable->value); + } +} diff --git a/webpack.mix.js b/webpack.mix.js index 39dfe16484..bb38ecedd0 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -122,6 +122,7 @@ mix .js("resources/js/processes/scripts/preview.js", "public/js/processes/scripts") .js("resources/js/processes/export/index.js", "public/js/processes/export") .js("resources/js/processes/environment-variables/index.js", "public/js/processes/environment-variables") + .js("resources/js/processes/environment-variables/edit.js", "public/js/processes/environment-variables") .js("resources/js/processes/import/index.js", "public/js/processes/import") .js("resources/js/processes/screens/index.js", "public/js/processes/screens") .js("resources/js/processes/screens/edit.js", "public/js/processes/screens")