From 052f28ccf18264e40b45dc8ff0c9ceb8b04916e6 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Thu, 23 Jul 2026 12:37:44 -0600 Subject: [PATCH] FOUR-32329: Handle unavailable assets in DevLink bundles --- .../Exception/BundleIntegrityException.php | 45 +++++++++++ .../DevLinkRemoteBundleException.php | 25 ++++++ .../Controllers/Api/DevLinkController.php | 9 +++ ProcessMaker/Jobs/DevLinkInstall.php | 12 ++- ProcessMaker/Models/Bundle.php | 18 +++++ ProcessMaker/Models/BundleAsset.php | 78 ++++++++++++++++--- ProcessMaker/Models/DevLink.php | 31 +++++--- .../devlink/components/BundleAssetListing.vue | 31 ++++++-- .../admin/devlink/components/BundleAssets.vue | 18 +++-- .../admin/devlink/components/BundleDetail.vue | 63 ++++++++++++++- tests/Feature/Api/DevLinkTest.php | 78 +++++++++++++++++++ tests/Model/BundleAssetTest.php | 53 +++++++++++++ tests/Model/BundleTest.php | 63 +++++++++++++-- tests/Model/DevLinkTest.php | 47 ++++++++++- 14 files changed, 524 insertions(+), 47 deletions(-) create mode 100644 ProcessMaker/Exception/BundleIntegrityException.php create mode 100644 ProcessMaker/Exception/DevLinkRemoteBundleException.php diff --git a/ProcessMaker/Exception/BundleIntegrityException.php b/ProcessMaker/Exception/BundleIntegrityException.php new file mode 100644 index 0000000000..f2d8f853cd --- /dev/null +++ b/ProcessMaker/Exception/BundleIntegrityException.php @@ -0,0 +1,45 @@ +invalidAssets = $invalidAssets + ->map(fn (BundleAsset $asset) => $asset->integrityDetails()) + ->values() + ->all(); + + parent::__construct(__( + 'The bundle :bundle contains unavailable assets and cannot be exported.', + ['bundle' => $bundle->name] + )); + } + + public function invalidAssets(): array + { + return $this->invalidAssets; + } + + public function render(): JsonResponse + { + return response()->json([ + 'error' => [ + 'code' => 422, + 'message' => $this->getMessage(), + ], + 'errors' => [ + 'assets' => $this->invalidAssets, + ], + ], 422); + } +} diff --git a/ProcessMaker/Exception/DevLinkRemoteBundleException.php b/ProcessMaker/Exception/DevLinkRemoteBundleException.php new file mode 100644 index 0000000000..3d97f0c31c --- /dev/null +++ b/ProcessMaker/Exception/DevLinkRemoteBundleException.php @@ -0,0 +1,25 @@ +map(function (array $asset) { + $type = class_basename($asset['asset_type'] ?? 'Asset'); + $id = $asset['asset_id'] ?? '?'; + $bundleAssetId = $asset['bundle_asset_id'] ?? '?'; + + return "$type #$id (bundle asset #$bundleAssetId)"; + })->implode(', '); + + parent::__construct(__( + 'The remote bundle contains unavailable assets: :assets. Repair the bundle on the source instance and try again.', + ['assets' => $assets] + ), 0, $previous); + } +} diff --git a/ProcessMaker/Http/Controllers/Api/DevLinkController.php b/ProcessMaker/Http/Controllers/Api/DevLinkController.php index 29825f4d38..988cc3f4ac 100644 --- a/ProcessMaker/Http/Controllers/Api/DevLinkController.php +++ b/ProcessMaker/Http/Controllers/Api/DevLinkController.php @@ -424,6 +424,15 @@ private function normalizeUrl(string $url): string public function deleteBundleAsset(BundleAsset $bundleAsset) { + if ( + !$bundleAsset->bundle->editable() + && $bundleAsset->integrity_status === BundleAsset::INTEGRITY_VALID + ) { + throw ValidationException::withMessages([ + '*' => __('Only unavailable assets can be removed from an installed bundle.'), + ]); + } + $bundleAsset->delete(); return response()->json(['message' => 'Bundle asset association deleted.'], 200); diff --git a/ProcessMaker/Jobs/DevLinkInstall.php b/ProcessMaker/Jobs/DevLinkInstall.php index c30519e201..15d30ead53 100644 --- a/ProcessMaker/Jobs/DevLinkInstall.php +++ b/ProcessMaker/Jobs/DevLinkInstall.php @@ -9,6 +9,8 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use ProcessMaker\Exception\DevLinkRemoteBundleException; use ProcessMaker\ImportExport\Logger; use ProcessMaker\Jobs\ImportV2; use ProcessMaker\Models\Bundle; @@ -49,7 +51,7 @@ public function __construct( public function handle(): void { //log - \Log::info('DevLinkInstall job started: ' . $this->devLinkId); + Log::info('DevLinkInstall job started: ' . $this->devLinkId); $devLink = DevLink::findOrFail($this->devLinkId); $logger = new Logger($this->userId); @@ -82,7 +84,13 @@ public function handle(): void public function failed(Throwable $exception): void { - (new Logger($this->userId))->exception($exception); + $logger = new Logger($this->userId); + if ($exception instanceof DevLinkRemoteBundleException) { + Log::error($exception->getMessage(), ['exception' => $exception]); + $logger->error($exception->getMessage()); + } else { + $logger->exception($exception); + } // Unlock the job // We can't use $this->lock->release() here because this is run in a new instance diff --git a/ProcessMaker/Models/Bundle.php b/ProcessMaker/Models/Bundle.php index 98f6328ab5..3971c973a3 100644 --- a/ProcessMaker/Models/Bundle.php +++ b/ProcessMaker/Models/Bundle.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Support\Facades\Http; +use ProcessMaker\Exception\BundleIntegrityException; use ProcessMaker\Exception\ExporterNotSupported; use ProcessMaker\Exception\ValidationException; use ProcessMaker\ImportExport\Importer; @@ -78,6 +79,8 @@ public function getAssetCountAttribute() public function export() { + $this->assertIntegrity(); + $exports = []; foreach ($this->assets as $bundleAsset) { @@ -93,6 +96,21 @@ public function export() return $exports; } + public function invalidAssets() + { + return $this->assets->filter( + fn (BundleAsset $asset) => $asset->integrity_status !== BundleAsset::INTEGRITY_VALID + ); + } + + public function assertIntegrity(): void + { + $invalidAssets = $this->invalidAssets(); + if ($invalidAssets->isNotEmpty()) { + throw new BundleIntegrityException($this, $invalidAssets); + } + } + public function exportSettings() { $exports = []; diff --git a/ProcessMaker/Models/BundleAsset.php b/ProcessMaker/Models/BundleAsset.php index d0af2d536b..979e0f4003 100644 --- a/ProcessMaker/Models/BundleAsset.php +++ b/ProcessMaker/Models/BundleAsset.php @@ -3,15 +3,22 @@ namespace ProcessMaker\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Str; use ProcessMaker\Enums\ExporterMap; class BundleAsset extends ProcessMakerModel { use HasFactory; + public const INTEGRITY_VALID = 'valid'; + + public const INTEGRITY_MISSING = 'missing'; + + public const INTEGRITY_TYPE_UNAVAILABLE = 'type_unavailable'; + protected $guarded = ['id']; - protected $appends = ['name', 'url', 'type', 'owner_name', 'categories']; + protected $appends = ['name', 'url', 'type', 'owner_name', 'categories', 'integrity_status']; const DATA_SOURCE_CLASS = 'ProcessMaker\Packages\Connectors\DataSources\Models\DataSource'; @@ -23,9 +30,11 @@ class BundleAsset extends ProcessMakerModel const PM_BLOCK_CLASS = 'ProcessMaker\Package\PackagePmBlocks\Models\PmBlock'; - public static function canExport(ProcessMakerModel $asset) + public static function canExport(?ProcessMakerModel $asset) { - return method_exists($asset, 'export') && ExporterMap::getExporterClassForModel($asset); + return $asset !== null + && method_exists($asset, 'export') + && ExporterMap::getExporterClassForModel($asset); } public function bundle() @@ -50,18 +59,30 @@ public static function makeKey(ProcessMakerModel $asset) public function getNameAttribute() { + $asset = $this->resolvedAsset(); + if ($asset === null) { + return __('Missing :type #:id', [ + 'type' => $this->typeLabel(), + 'id' => $this->asset_id, + ]); + } + if ( $this->asset_type === Screen::class || $this->asset_type === Script::class ) { - return $this->asset->title; + return $asset->title; } - return $this->asset->name; + return $asset->name; } public function getUrlAttribute() { + if ($this->integrity_status !== self::INTEGRITY_VALID) { + return null; + } + switch($this->asset_type) { case Screen::class: return "/designer/screen-builder/{$this->asset_id}/edit"; @@ -110,8 +131,9 @@ public function getTypeAttribute() public function getOwnerNameAttribute() { - if ($this->asset && method_exists($this->asset, 'user') && $this->asset->user) { - return $this->asset->user->firstname . ' ' . $this->asset->user->lastname; + $asset = $this->resolvedAsset(); + if ($asset && method_exists($asset, 'user') && $asset->user) { + return $asset->user->firstname . ' ' . $asset->user->lastname; } return null; @@ -123,10 +145,48 @@ public function getCategoriesAttribute() return []; } - if ($this->asset && method_exists($this->asset, 'categories')) { - return $this->asset->categories->pluck('name')->toArray(); + $asset = $this->resolvedAsset(); + if ($asset && method_exists($asset, 'categories')) { + return $asset->categories->pluck('name')->toArray(); } return []; } + + public function getIntegrityStatusAttribute(): string + { + if (!class_exists($this->asset_type)) { + return self::INTEGRITY_TYPE_UNAVAILABLE; + } + + return $this->resolvedAsset() === null + ? self::INTEGRITY_MISSING + : self::INTEGRITY_VALID; + } + + public function integrityDetails(): array + { + return [ + 'bundle_asset_id' => $this->id, + 'asset_type' => $this->asset_type, + 'asset_id' => $this->asset_id, + 'integrity_status' => $this->integrity_status, + ]; + } + + private function resolvedAsset(): ?ProcessMakerModel + { + if (!class_exists($this->asset_type)) { + return null; + } + + return $this->asset; + } + + private function typeLabel(): string + { + $type = $this->type ?? class_basename($this->asset_type); + + return Str::headline($type); + } } diff --git a/ProcessMaker/Models/DevLink.php b/ProcessMaker/Models/DevLink.php index e7ad53939d..7637d24253 100644 --- a/ProcessMaker/Models/DevLink.php +++ b/ProcessMaker/Models/DevLink.php @@ -3,8 +3,10 @@ namespace ProcessMaker\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; +use ProcessMaker\Exception\DevLinkRemoteBundleException; use ProcessMaker\ImportExport\Importer; use ProcessMaker\ImportExport\Logger; use ProcessMaker\ImportExport\Options; @@ -143,19 +145,28 @@ public function installRemoteBundle($remoteBundleId, $updateType) $this->logger->status(__('Downloading bundle from remote instance')); - $bundleInfo = $this->remoteBundle($remoteBundleId)->json(); + try { + $bundleInfo = $this->remoteBundle($remoteBundleId)->json(); - $bundleExport = $this->client()->get( - route('api.devlink.export-local-bundle', ['bundle' => $remoteBundleId], false) - )->json(); + $bundleExport = $this->client()->get( + route('api.devlink.export-local-bundle', ['bundle' => $remoteBundleId], false) + )->json(); - $bundleSettingsExport = $this->client()->get( - route('api.devlink.export-local-bundle-settings', ['bundle' => $remoteBundleId], false) - )->json(); + $bundleSettingsExport = $this->client()->get( + route('api.devlink.export-local-bundle-settings', ['bundle' => $remoteBundleId], false) + )->json(); - $bundleSettingsPayloads = $this->client()->get( - route('api.devlink.export-local-bundle-setting-payloads', ['bundle' => $remoteBundleId], false) - )->json(); + $bundleSettingsPayloads = $this->client()->get( + route('api.devlink.export-local-bundle-setting-payloads', ['bundle' => $remoteBundleId], false) + )->json(); + } catch (RequestException $exception) { + $invalidAssets = $exception->response->json('errors.assets'); + if (is_array($invalidAssets) && $invalidAssets !== []) { + throw new DevLinkRemoteBundleException($invalidAssets, $exception); + } + + throw $exception; + } $bundle = Bundle::updateOrCreate( [ diff --git a/resources/js/admin/devlink/components/BundleAssetListing.vue b/resources/js/admin/devlink/components/BundleAssetListing.vue index 0fc95db08d..dafd4bfe71 100644 --- a/resources/js/admin/devlink/components/BundleAssetListing.vue +++ b/resources/js/admin/devlink/components/BundleAssetListing.vue @@ -18,15 +18,21 @@ const loadAssets = async () => { loading.value = true; const response = await window.ProcessMaker.apiClient.get(`/api/1.0/devlink/local-bundles/${bundleId}`); bundle.value = response.data; - items.value = response.data.assets.filter(asset => asset.type.toUpperCase() === route.params.type.toUpperCase()); + items.value = response.data.assets.filter( + (asset) => asset.type && asset.type.toUpperCase() === route.params.type.toUpperCase(), + ); loading.value = false; }; const remove = async (asset) => { - const confirm = await vue.$bvModal.msgBoxConfirm(vue.$t('Are you sure you want to remote this asset from the bundle?'), { - okTitle: vue.$t('Ok'), - cancelTitle: vue.$t('Cancel'), - }); + const confirm = await vue.$bvModal.msgBoxConfirm( + vue.$t('Remove this association from the bundle? The underlying asset will not be deleted.'), + { + okTitle: vue.$t('Remove from bundle'), + okVariant: 'danger', + cancelTitle: vue.$t('Cancel'), + }, + ); if (!confirm) { return; } @@ -108,12 +114,23 @@ const fields = [ class="asset-listing-table" >