AppLifecycle   F
last analyzed

Complexity

Total Complexity 65

Size/Duplication

Total Lines 558
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 251
dl 0
loc 558
rs 3.2
c 0
b 0
f 0
wmc 65

22 Methods

Rating   Name   Duplication   Size   Complexity  
A updateCustomEntities() 0 11 2
A updateMetadata() 0 4 1
A install() 0 26 3
A removeAppAndRole() 0 30 4
A __construct() 0 33 1
A getWebhooks() 0 36 3
A updateModules() 0 27 3
A getDecorated() 0 3 1
B assertAppSecretIsPresentForApplicableFeatures() 0 31 8
A ensureIsCompatible() 0 5 2
F updateApp() 0 105 13
A getIcon() 0 7 2
A enrichInstallMetadata() 0 19 1
A doesAllowDisabling() 0 20 4
A deleteAclRole() 0 25 4
A loadAppByName() 0 9 1
A handleConfigUpdates() 0 18 3
A getDefaultLocale() 0 11 1
A update() 0 11 1
A delete() 0 11 2
A loadApp() 0 6 1
A updateAclRole() 0 32 4

How to fix   Complexity   

Complex Class

Complex classes like AppLifecycle often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AppLifecycle, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\Framework\App\Lifecycle;
4
5
use Composer\Semver\VersionParser;
6
use Doctrine\DBAL\Connection;
7
use Shopware\Administration\Snippet\AppAdministrationSnippetPersister;
8
use Shopware\Core\Defaults;
9
use Shopware\Core\Framework\Api\Acl\Role\AclRoleDefinition;
10
use Shopware\Core\Framework\Api\Acl\Role\AclRoleEntity;
11
use Shopware\Core\Framework\Api\Util\AccessKeyHelper;
12
use Shopware\Core\Framework\App\AppEntity;
13
use Shopware\Core\Framework\App\AppException;
14
use Shopware\Core\Framework\App\AppStateService;
15
use Shopware\Core\Framework\App\Event\AppDeletedEvent;
16
use Shopware\Core\Framework\App\Event\AppInstalledEvent;
17
use Shopware\Core\Framework\App\Event\AppUpdatedEvent;
18
use Shopware\Core\Framework\App\Event\Hooks\AppDeletedHook;
19
use Shopware\Core\Framework\App\Event\Hooks\AppInstalledHook;
20
use Shopware\Core\Framework\App\Event\Hooks\AppUpdatedHook;
21
use Shopware\Core\Framework\App\Exception\AppRegistrationException;
22
use Shopware\Core\Framework\App\Flow\Action\Action;
23
use Shopware\Core\Framework\App\Lifecycle\Persister\ActionButtonPersister;
24
use Shopware\Core\Framework\App\Lifecycle\Persister\CmsBlockPersister;
25
use Shopware\Core\Framework\App\Lifecycle\Persister\CustomFieldPersister;
26
use Shopware\Core\Framework\App\Lifecycle\Persister\FlowActionPersister;
27
use Shopware\Core\Framework\App\Lifecycle\Persister\FlowEventPersister;
28
use Shopware\Core\Framework\App\Lifecycle\Persister\PaymentMethodPersister;
29
use Shopware\Core\Framework\App\Lifecycle\Persister\PermissionPersister;
30
use Shopware\Core\Framework\App\Lifecycle\Persister\RuleConditionPersister;
31
use Shopware\Core\Framework\App\Lifecycle\Persister\ScriptPersister;
32
use Shopware\Core\Framework\App\Lifecycle\Persister\TaxProviderPersister;
33
use Shopware\Core\Framework\App\Lifecycle\Persister\TemplatePersister;
34
use Shopware\Core\Framework\App\Lifecycle\Persister\WebhookPersister;
35
use Shopware\Core\Framework\App\Lifecycle\Registration\AppRegistrationService;
36
use Shopware\Core\Framework\App\Manifest\Manifest;
37
use Shopware\Core\Framework\App\Manifest\Xml\Module;
38
use Shopware\Core\Framework\App\Validation\ConfigValidator;
39
use Shopware\Core\Framework\Context;
40
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
41
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
42
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
43
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
44
use Shopware\Core\Framework\Log\Package;
45
use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
46
use Shopware\Core\Framework\Plugin\Util\AssetService;
47
use Shopware\Core\Framework\Script\Execution\ScriptExecutor;
48
use Shopware\Core\Framework\Uuid\Uuid;
49
use Shopware\Core\System\CustomEntity\CustomEntityLifecycleService;
50
use Shopware\Core\System\CustomEntity\Schema\CustomEntitySchemaUpdater;
51
use Shopware\Core\System\CustomEntity\Xml\Field\AssociationField;
52
use Shopware\Core\System\Language\LanguageEntity;
53
use Shopware\Core\System\Locale\LocaleEntity;
54
use Shopware\Core\System\SystemConfig\SystemConfigService;
55
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
56
57
/**
58
 * @internal
59
 */
60
#[Package('core')]
61
class AppLifecycle extends AbstractAppLifecycle
62
{
63
    public function __construct(
64
        private readonly EntityRepository $appRepository,
65
        private readonly PermissionPersister $permissionPersister,
66
        private readonly CustomFieldPersister $customFieldPersister,
67
        private readonly ActionButtonPersister $actionButtonPersister,
68
        private readonly TemplatePersister $templatePersister,
69
        private readonly ScriptPersister $scriptPersister,
70
        private readonly WebhookPersister $webhookPersister,
71
        private readonly PaymentMethodPersister $paymentMethodPersister,
72
        private readonly TaxProviderPersister $taxProviderPersister,
73
        private readonly RuleConditionPersister $ruleConditionPersister,
74
        private readonly CmsBlockPersister $cmsBlockPersister,
75
        private readonly AbstractAppLoader $appLoader,
76
        private readonly EventDispatcherInterface $eventDispatcher,
77
        private readonly AppRegistrationService $registrationService,
78
        private readonly AppStateService $appStateService,
79
        private readonly EntityRepository $languageRepository,
80
        private readonly SystemConfigService $systemConfigService,
81
        private readonly ConfigValidator $configValidator,
82
        private readonly EntityRepository $integrationRepository,
83
        private readonly EntityRepository $aclRoleRepository,
84
        private readonly AssetService $assetService,
85
        private readonly ScriptExecutor $scriptExecutor,
86
        private readonly string $projectDir,
87
        private readonly Connection $connection,
88
        private readonly FlowActionPersister $flowBuilderActionPersister,
89
        private readonly ?AppAdministrationSnippetPersister $appAdministrationSnippetPersister,
90
        private readonly CustomEntitySchemaUpdater $customEntitySchemaUpdater,
91
        private readonly CustomEntityLifecycleService $customEntityLifecycleService,
92
        private readonly string $shopwareVersion,
93
        private readonly FlowEventPersister $flowEventPersister,
94
        private readonly string $env
95
    ) {
96
    }
97
98
    public function getDecorated(): AbstractAppLifecycle
99
    {
100
        throw new DecorationPatternException(self::class);
101
    }
102
103
    public function install(Manifest $manifest, bool $activate, Context $context): void
104
    {
105
        $this->ensureIsCompatible($manifest);
106
107
        $app = $this->loadAppByName($manifest->getMetadata()->getName(), $context);
108
        if ($app) {
109
            throw AppException::alreadyInstalled($manifest->getMetadata()->getName());
110
        }
111
112
        $defaultLocale = $this->getDefaultLocale($context);
113
        $metadata = $manifest->getMetadata()->toArray($defaultLocale);
114
        $appId = Uuid::randomHex();
115
        $roleId = Uuid::randomHex();
116
        $metadata = $this->enrichInstallMetadata($manifest, $metadata, $roleId);
117
118
        $app = $this->updateApp($manifest, $metadata, $appId, $roleId, $defaultLocale, $context, true);
119
120
        $event = new AppInstalledEvent($app, $manifest, $context);
121
        $this->eventDispatcher->dispatch($event);
122
        $this->scriptExecutor->execute(new AppInstalledHook($event));
123
124
        if ($activate) {
125
            $this->appStateService->activateApp($appId, $context);
126
        }
127
128
        $this->updateAclRole($app->getName(), $context);
129
    }
130
131
    /**
132
     * @param array{id: string, roleId: string} $app
133
     */
134
    public function update(Manifest $manifest, array $app, Context $context): void
135
    {
136
        $this->ensureIsCompatible($manifest);
137
138
        $defaultLocale = $this->getDefaultLocale($context);
139
        $metadata = $manifest->getMetadata()->toArray($defaultLocale);
140
        $appEntity = $this->updateApp($manifest, $metadata, $app['id'], $app['roleId'], $defaultLocale, $context, false);
141
142
        $event = new AppUpdatedEvent($appEntity, $manifest, $context);
143
        $this->eventDispatcher->dispatch($event);
144
        $this->scriptExecutor->execute(new AppUpdatedHook($event));
145
    }
146
147
    /**
148
     * @param array{id: string} $app
149
     */
150
    public function delete(string $appName, array $app, Context $context, bool $keepUserData = false): void
151
    {
152
        $appEntity = $this->loadApp($app['id'], $context);
153
154
        if ($appEntity->isActive()) {
155
            $this->appStateService->deactivateApp($appEntity->getId(), $context);
156
        }
157
158
        $this->removeAppAndRole($appEntity, $context, $keepUserData, true);
159
        $this->assetService->removeAssets($appEntity->getName());
160
        $this->customEntitySchemaUpdater->update();
161
    }
162
163
    public function ensureIsCompatible(Manifest $manifest): void
164
    {
165
        $versionParser = new VersionParser();
166
        if (!$manifest->getMetadata()->getCompatibility()->matches($versionParser->parseConstraints($this->shopwareVersion))) {
167
            throw AppException::notCompatible($manifest->getMetadata()->getName());
168
        }
169
    }
170
171
    /**
172
     * @param array<string, mixed> $metadata
173
     */
174
    private function updateApp(
175
        Manifest $manifest,
176
        array $metadata,
177
        string $id,
178
        string $roleId,
179
        string $defaultLocale,
180
        Context $context,
181
        bool $install
182
    ): AppEntity {
183
        // accessToken is not set on update, but in that case we don't run registration, so we won't need it
184
        /** @var string $secretAccessKey */
185
        $secretAccessKey = $metadata['accessToken'] ?? '';
186
        unset($metadata['accessToken'], $metadata['icon']);
187
        $metadata['path'] = str_replace($this->projectDir . '/', '', $manifest->getPath());
188
        $metadata['id'] = $id;
189
        $metadata['modules'] = [];
190
        $metadata['iconRaw'] = $this->getIcon($manifest);
191
        $metadata['cookies'] = $manifest->getCookies() !== null ? $manifest->getCookies()->getCookies() : [];
192
        $metadata['baseAppUrl'] = $manifest->getAdmin() !== null ? $manifest->getAdmin()->getBaseAppUrl() : null;
193
        $metadata['allowedHosts'] = $manifest->getAllHosts();
194
        $metadata['templateLoadPriority'] = $manifest->getStorefront() ? $manifest->getStorefront()->getTemplateLoadPriority() : 0;
195
196
        $this->updateMetadata($metadata, $context);
197
198
        $app = $this->loadApp($id, $context);
199
200
        $this->updateCustomEntities($app->getId(), $app->getPath(), $manifest);
201
202
        $this->permissionPersister->updatePrivileges($manifest->getPermissions(), $roleId);
203
204
        // If the app has no secret yet, but now specifies setup data we do a registration to get an app secret
205
        // this mostly happens during install, but may happen in the update case if the app previously worked without an external server
206
        if (!$app->getAppSecret() && $manifest->getSetup()) {
207
            try {
208
                $this->registrationService->registerApp($manifest, $id, $secretAccessKey, $context);
209
            } catch (AppRegistrationException $e) {
210
                $this->removeAppAndRole($app, $context);
211
212
                throw $e;
213
            }
214
        }
215
216
        // Refetch app to get secret after registration
217
        $app = $this->loadApp($id, $context);
218
219
        try {
220
            $this->assertAppSecretIsPresentForApplicableFeatures($app, $manifest);
221
        } catch (AppException $e) {
222
            $this->removeAppAndRole($app, $context);
223
224
            throw $e;
225
        }
226
227
        $flowActions = $this->appLoader->getFlowActions($app);
228
229
        if ($flowActions) {
230
            $this->flowBuilderActionPersister->updateActions($flowActions, $id, $context, $defaultLocale);
231
        }
232
233
        $webhooks = $this->getWebhooks($manifest, $flowActions, $id, $defaultLocale, (bool) $app->getAppSecret());
234
        $context->scope(Context::SYSTEM_SCOPE, function (Context $context) use ($webhooks, $id): void {
235
            $this->webhookPersister->updateWebhooksFromArray($webhooks, $id, $context);
236
        });
237
238
        $flowEvents = $this->appLoader->getFlowEvents($app);
239
240
        if ($flowEvents) {
241
            $this->flowEventPersister->updateEvents($flowEvents, $id, $context, $defaultLocale);
242
        }
243
244
        // we need an app secret to securely communicate with apps
245
        // therefore we only install webhooks, modules, tax providers and payment methods if we have a secret
246
        if ($app->getAppSecret()) {
247
            $this->paymentMethodPersister->updatePaymentMethods($manifest, $id, $defaultLocale, $context);
248
            $this->taxProviderPersister->updateTaxProviders($manifest, $id, $defaultLocale, $context);
249
250
            $this->updateModules($manifest, $id, $defaultLocale, $context);
251
        }
252
253
        $this->ruleConditionPersister->updateConditions($manifest, $id, $defaultLocale, $context);
254
        $this->actionButtonPersister->updateActions($manifest, $id, $defaultLocale, $context);
255
        $this->templatePersister->updateTemplates($manifest, $id, $context);
256
        $this->scriptPersister->updateScripts($id, $context);
257
        $this->customFieldPersister->updateCustomFields($manifest, $id, $context);
258
        $this->assetService->copyAssetsFromApp($app->getName(), $app->getPath());
259
260
        $cmsExtensions = $this->appLoader->getCmsExtensions($app);
261
        if ($cmsExtensions) {
262
            $this->cmsBlockPersister->updateCmsBlocks($cmsExtensions, $id, $defaultLocale, $context);
263
        }
264
265
        $updatePayload = [
266
            'id' => $app->getId(),
267
            'configurable' => $this->handleConfigUpdates($app, $manifest, $install, $context),
268
            'allowDisable' => $this->doesAllowDisabling($app, $context),
269
        ];
270
        $this->updateMetadata($updatePayload, $context);
271
272
        // updates the snippets if the administration bundle is available
273
        if ($this->appAdministrationSnippetPersister !== null) {
274
            $snippets = $this->appLoader->getSnippets($app);
275
            $this->appAdministrationSnippetPersister->updateSnippets($app, $snippets, $context);
276
        }
277
278
        return $app;
279
    }
280
281
    private function removeAppAndRole(AppEntity $app, Context $context, bool $keepUserData = false, bool $softDelete = false): void
282
    {
283
        // throw event before deleting app from db as it may be delivered via webhook to the deleted app
284
        $event = new AppDeletedEvent($app->getId(), $context, $keepUserData);
285
        $this->eventDispatcher->dispatch($event);
286
        $this->scriptExecutor->execute(new AppDeletedHook($event));
287
288
        $context->scope(Context::SYSTEM_SCOPE, function (Context $context) use ($app, $softDelete, $keepUserData): void {
289
            if (!$keepUserData) {
290
                $config = $this->appLoader->getConfiguration($app);
291
292
                if ($config) {
293
                    $this->systemConfigService->deleteExtensionConfiguration($app->getName(), $config);
294
                }
295
            }
296
297
            $this->appRepository->delete([['id' => $app->getId()]], $context);
298
299
            if ($softDelete) {
300
                $this->integrationRepository->update([[
301
                    'id' => $app->getIntegrationId(),
302
                    'deletedAt' => new \DateTimeImmutable(),
303
                ]], $context);
304
                $this->permissionPersister->softDeleteRole($app->getAclRoleId());
305
            } else {
306
                $this->integrationRepository->delete([['id' => $app->getIntegrationId()]], $context);
307
                $this->permissionPersister->removeRole($app->getAclRoleId());
308
            }
309
310
            $this->deleteAclRole($app->getName(), $context);
311
        });
312
    }
313
314
    /**
315
     * @param array<string, mixed> $metadata
316
     */
317
    private function updateMetadata(array $metadata, Context $context): void
318
    {
319
        $context->scope(Context::SYSTEM_SCOPE, function (Context $context) use ($metadata): void {
320
            $this->appRepository->upsert([$metadata], $context);
321
        });
322
    }
323
324
    /**
325
     * @param array<string, mixed> $metadata
326
     *
327
     * @return array<string, mixed>
328
     */
329
    private function enrichInstallMetadata(Manifest $manifest, array $metadata, string $roleId): array
330
    {
331
        $secret = AccessKeyHelper::generateSecretAccessKey();
332
333
        $metadata['integration'] = [
334
            'label' => $manifest->getMetadata()->getName(),
335
            'accessKey' => AccessKeyHelper::generateAccessKey('integration'),
336
            'secretAccessKey' => $secret,
337
            'admin' => false,
338
        ];
339
        $metadata['aclRole'] = [
340
            'id' => $roleId,
341
            'name' => $manifest->getMetadata()->getName(),
342
        ];
343
        $metadata['accessToken'] = $secret;
344
        // Always install as inactive, activation will be handled by `AppStateService` in `install()` method.
345
        $metadata['active'] = false;
346
347
        return $metadata;
348
    }
349
350
    private function loadApp(string $id, Context $context): AppEntity
351
    {
352
        /** @var AppEntity $app */
353
        $app = $this->appRepository->search(new Criteria([$id]), $context)->first();
354
355
        return $app;
356
    }
357
358
    private function loadAppByName(string $name, Context $context): ?AppEntity
359
    {
360
        $criteria = new Criteria();
361
        $criteria->addFilter(new EqualsFilter('name', $name));
362
363
        /** @var AppEntity|null $app */
364
        $app = $this->appRepository->search($criteria, $context)->first();
365
366
        return $app;
367
    }
368
369
    private function updateModules(Manifest $manifest, string $id, string $defaultLocale, Context $context): void
370
    {
371
        $payload = [
372
            'id' => $id,
373
            'mainModule' => null,
374
            'modules' => [],
375
        ];
376
377
        if ($manifest->getAdmin() !== null) {
378
            if ($manifest->getAdmin()->getMainModule() !== null) {
379
                $payload['mainModule'] = [
380
                    'source' => $manifest->getAdmin()->getMainModule()->getSource(),
381
                ];
382
            }
383
384
            $payload['modules'] = array_reduce(
385
                $manifest->getAdmin()->getModules(),
386
                static function (array $modules, Module $module) use ($defaultLocale) {
387
                    $modules[] = $module->toArray($defaultLocale);
388
389
                    return $modules;
390
                },
391
                []
392
            );
393
        }
394
395
        $this->appRepository->update([$payload], $context);
396
    }
397
398
    private function getDefaultLocale(Context $context): string
399
    {
400
        $criteria = new Criteria([Defaults::LANGUAGE_SYSTEM]);
401
        $criteria->addAssociation('locale');
402
403
        /** @var LanguageEntity $language */
404
        $language = $this->languageRepository->search($criteria, $context)->first();
405
        /** @var LocaleEntity $locale */
406
        $locale = $language->getLocale();
407
408
        return $locale->getCode();
409
    }
410
411
    private function updateAclRole(string $appName, Context $context): void
412
    {
413
        $criteria = new Criteria();
414
        $criteria->addFilter(new NotFilter(
415
            NotFilter::CONNECTION_AND,
416
            [new EqualsFilter('users.id', null)]
417
        ));
418
        $roles = $this->aclRoleRepository->search($criteria, $context);
419
420
        $newPrivileges = [
421
            'app.' . $appName,
422
        ];
423
        $dataUpdate = [];
424
425
        /** @var AclRoleEntity $role */
426
        foreach ($roles as $role) {
427
            $currentPrivileges = $role->getPrivileges();
428
429
            if (\in_array('app.all', $currentPrivileges, true)) {
430
                $currentPrivileges = array_merge($currentPrivileges, $newPrivileges);
431
                $currentPrivileges = array_unique($currentPrivileges);
432
433
                $dataUpdate[] = [
434
                    'id' => $role->getId(),
435
                    'privileges' => $currentPrivileges,
436
                ];
437
            }
438
        }
439
440
        if (\count($dataUpdate) > 0) {
441
            $context->scope(Context::SYSTEM_SCOPE, function (Context $context) use ($dataUpdate): void {
442
                $this->aclRoleRepository->update($dataUpdate, $context);
443
            });
444
        }
445
    }
446
447
    private function deleteAclRole(string $appName, Context $context): void
448
    {
449
        $criteria = new Criteria();
450
        $criteria->addFilter(new EqualsFilter('app.id', null));
451
        $roles = $this->aclRoleRepository->search($criteria, $context);
452
453
        $appPrivileges = 'app.' . $appName;
454
        $dataUpdate = [];
455
456
        /** @var AclRoleEntity $role */
457
        foreach ($roles as $role) {
458
            $currentPrivileges = $role->getPrivileges();
459
460
            if (($key = array_search($appPrivileges, $currentPrivileges, true)) !== false) {
461
                unset($currentPrivileges[$key]);
462
463
                $dataUpdate[] = [
464
                    'id' => $role->getId(),
465
                    'privileges' => $currentPrivileges,
466
                ];
467
            }
468
        }
469
470
        if (\count($dataUpdate) > 0) {
471
            $this->aclRoleRepository->update($dataUpdate, $context);
472
        }
473
    }
474
475
    private function updateCustomEntities(string $appId, string $appPath, Manifest $manifest): void
476
    {
477
        $entities = $this->customEntityLifecycleService->updateApp($appId, $appPath)?->getEntities()?->getEntities();
478
479
        foreach ($entities ?? [] as $entity) {
480
            $manifest->addPermissions([
481
                $entity->getName() => [
482
                    AclRoleDefinition::PRIVILEGE_READ,
483
                    AclRoleDefinition::PRIVILEGE_CREATE,
484
                    AclRoleDefinition::PRIVILEGE_UPDATE,
485
                    AclRoleDefinition::PRIVILEGE_DELETE,
486
                ],
487
            ]);
488
        }
489
    }
490
491
    private function handleConfigUpdates(AppEntity $app, Manifest $manifest, bool $install, Context $context): bool
492
    {
493
        $config = $this->appLoader->getConfiguration($app);
494
        if (!$config) {
495
            return false;
496
        }
497
498
        $errors = $this->configValidator->validate($manifest, null);
499
        $configError = $errors->first();
500
501
        if ($configError) {
502
            // only one error can be in the returned collection
503
            throw AppException::invalidConfiguration($manifest->getMetadata()->getName(), $configError);
504
        }
505
506
        $this->systemConfigService->saveConfig($config, $app->getName() . '.config.', $install);
507
508
        return true;
509
    }
510
511
    private function doesAllowDisabling(AppEntity $app, Context $context): bool
512
    {
513
        $allow = true;
514
515
        $entities = $this->connection->fetchFirstColumn(
516
            'SELECT fields FROM custom_entity WHERE app_id = :id',
517
            ['id' => Uuid::fromHexToBytes($app->getId())]
518
        );
519
520
        foreach ($entities as $fields) {
521
            $fields = json_decode((string) $fields, true, 512, \JSON_THROW_ON_ERROR);
522
523
            foreach ($fields as $field) {
524
                $restricted = $field['onDelete'] ?? null;
525
526
                $allow = $restricted === AssociationField::RESTRICT ? false : $allow;
527
            }
528
        }
529
530
        return $allow;
531
    }
532
533
    /**
534
     * @return array<array<string, array{name: string, eventName: string, url: string, appId: string, active: bool, errorCount: int}>>
535
     */
536
    private function getWebhooks(Manifest $manifest, ?Action $flowActions, string $appId, string $defaultLocale, bool $hasAppSecret): array
537
    {
538
        $actions = [];
539
540
        if ($flowActions) {
541
            $actions = $flowActions->getActions()?->getActions() ?? [];
542
        }
543
544
        $webhooks = array_map(function ($action) use ($appId) {
545
            $name = $action->getMeta()->getName();
546
547
            return [
548
                'name' => $name,
549
                'eventName' => $name,
550
                'url' => $action->getMeta()->getUrl(),
551
                'appId' => $appId,
552
                'active' => true,
553
                'errorCount' => 0,
554
            ];
555
        }, $actions);
556
557
        if (!$hasAppSecret) {
558
            /** @phpstan-ignore-next-line - return typehint with active: bool, errorCount: int does not work here because active will always be true and errorCount will always be 0 */
559
            return $webhooks;
560
        }
561
562
        $manifestWebhooks = $manifest->getWebhooks()?->getWebhooks() ?? [];
563
        $webhooks = array_merge($webhooks, array_map(function ($webhook) use ($defaultLocale, $appId) {
564
            $payload = $webhook->toArray($defaultLocale);
565
            $payload['appId'] = $appId;
566
            $payload['eventName'] = $webhook->getEvent();
567
568
            return $payload;
569
        }, $manifestWebhooks));
570
571
        return $webhooks;
572
    }
573
574
    private function getIcon(Manifest $manifest): ?string
575
    {
576
        if (!$iconPath = $manifest->getMetadata()->getIcon()) {
577
            return null;
578
        }
579
580
        return $this->appLoader->loadFile($manifest->getPath(), $iconPath);
581
    }
582
583
    /**
584
     * Certain app features require an app secret to be set, if these features are used but no app secret
585
     * is set, we throw an exception in dev mode so the developer is aware
586
     */
587
    private function assertAppSecretIsPresentForApplicableFeatures(AppEntity $app, Manifest $manifest): void
588
    {
589
        if ($app->getAppSecret()) {
590
            return;
591
        }
592
593
        if ($this->env !== 'dev') {
594
            return;
595
        }
596
597
        $usedFeatures = [];
598
599
        if (\count($manifest->getAdmin()?->getModules() ?? []) > 0) {
600
            // if there is no app secret but the manifest specifies modules, throw an exception in dev mode
601
            $usedFeatures[] = 'Admin Modules';
602
        }
603
604
        if (\count($manifest->getPayments()?->getPaymentMethods() ?? []) > 0) {
605
            $usedFeatures[] = 'Payment Methods';
606
        }
607
608
        if (\count($manifest->getTax()?->getTaxProviders() ?? []) > 0) {
609
            $usedFeatures[] = 'Tax providers';
610
        }
611
612
        if (\count($manifest->getWebhooks()?->getWebhooks() ?? []) > 0) {
613
            $usedFeatures[] = 'Webhooks';
614
        }
615
616
        if (\count($usedFeatures) > 0) {
617
            throw AppException::appSecretRequiredForFeatures($app->getName(), $usedFeatures);
618
        }
619
    }
620
}
621