diff --git a/src/AppLifecycle.php b/src/AppLifecycle.php index edc7f1d..5cab82a 100644 --- a/src/AppLifecycle.php +++ b/src/AppLifecycle.php @@ -16,6 +16,7 @@ use Shopware\App\SDK\Event\ShopActivatedEvent; use Shopware\App\SDK\Event\ShopDeactivatedEvent; use Shopware\App\SDK\Event\ShopDeletedEvent; +use Shopware\App\SDK\Exception\MalformedWebhookBodyException; use Shopware\App\SDK\Exception\ShopNotFoundException; use Shopware\App\SDK\Registration\RegistrationService; use Shopware\App\SDK\Shop\ShopInterface; @@ -60,7 +61,11 @@ public function deactivate(RequestInterface $request): ResponseInterface } /** - * Handles the 'app.deleted' Hook to remove the shop from the repository + * Handles the 'app.deleted' Hook to remove the shop from the repository. + * + * When the merchant opts to keep the shop's data on uninstall, Shopware sends + * `keepUserData: true` in the webhook payload; the shop is then left untouched + * so a later re-registration can restore it. */ public function delete(RequestInterface $request): ResponseInterface { @@ -73,20 +78,46 @@ public function delete(RequestInterface $request): ResponseInterface return $response; } - $this->eventDispatcher?->dispatch(new BeforeShopDeletionEvent($request, $shop)); + $keepUserData = $this->shouldKeepUserData($request); - $this->shopRepository->deleteShop($shop->getShopId()); + $this->eventDispatcher?->dispatch(new BeforeShopDeletionEvent($request, $shop, $keepUserData)); - $this->eventDispatcher?->dispatch(new ShopDeletedEvent($request, $shop)); + if (!$keepUserData) { + $this->shopRepository->deleteShop($shop->getShopId()); + } + + $this->eventDispatcher?->dispatch(new ShopDeletedEvent($request, $shop, $keepUserData)); $this->logger->info('Shop uninstalled', [ 'shop-id' => $shop->getShopId(), 'shop-url' => $shop->getShopUrl(), + 'keep-user-data' => $keepUserData, ]); return $response; } + /** + * Reads the `keepUserData` flag from the app.deleted webhook payload, defaulting + * to false so the shop is removed unless Shopware explicitly asks to keep it. + * + * Invalid JSON means the webhook body itself is corrupt; it is rejected as a + * malformed body rather than silently treated as keepUserData=false. + * + * @throws MalformedWebhookBodyException when the request body is not valid JSON + */ + private function shouldKeepUserData(RequestInterface $request): bool + { + try { + $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw new MalformedWebhookBodyException(); + } + $request->getBody()->rewind(); + + return \is_array($body) && ($body['data']['payload']['keepUserData'] ?? false) === true; + } + private function findShop(RequestInterface $request): ?ShopInterface { try { diff --git a/src/Event/BeforeShopDeletionEvent.php b/src/Event/BeforeShopDeletionEvent.php index 9353b35..393f8bd 100644 --- a/src/Event/BeforeShopDeletionEvent.php +++ b/src/Event/BeforeShopDeletionEvent.php @@ -4,6 +4,25 @@ namespace Shopware\App\SDK\Event; +use Psr\Http\Message\RequestInterface; +use Shopware\App\SDK\Shop\ShopInterface; + class BeforeShopDeletionEvent extends AbstractAppLifecycleEvent { + public function __construct( + RequestInterface $request, + ShopInterface $shop, + private readonly bool $keepUserData = false, + ) { + parent::__construct($request, $shop); + } + + /** + * Whether the merchant chose to keep the shop's data when uninstalling the app. + * When true, the SDK leaves the shop in the repository instead of deleting it. + */ + public function keepUserData(): bool + { + return $this->keepUserData; + } } diff --git a/src/Event/ShopDeletedEvent.php b/src/Event/ShopDeletedEvent.php index ce8a317..9710ed9 100644 --- a/src/Event/ShopDeletedEvent.php +++ b/src/Event/ShopDeletedEvent.php @@ -4,6 +4,25 @@ namespace Shopware\App\SDK\Event; +use Psr\Http\Message\RequestInterface; +use Shopware\App\SDK\Shop\ShopInterface; + class ShopDeletedEvent extends AbstractAppLifecycleEvent { + public function __construct( + RequestInterface $request, + ShopInterface $shop, + private readonly bool $keepUserData = false, + ) { + parent::__construct($request, $shop); + } + + /** + * Whether the merchant chose to keep the shop's data when uninstalling the app. + * When true, the shop was left in the repository instead of being deleted. + */ + public function keepUserData(): bool + { + return $this->keepUserData; + } } diff --git a/tests/AppLifecycleTest.php b/tests/AppLifecycleTest.php index 0beb73b..4d36746 100644 --- a/tests/AppLifecycleTest.php +++ b/tests/AppLifecycleTest.php @@ -19,6 +19,7 @@ use Shopware\App\SDK\Event\ShopActivatedEvent; use Shopware\App\SDK\Event\ShopDeactivatedEvent; use Shopware\App\SDK\Event\ShopDeletedEvent; +use Shopware\App\SDK\Exception\MalformedWebhookBodyException; use Shopware\App\SDK\Registration\RegistrationService; use Shopware\App\SDK\Shop\ShopResolver; use Shopware\App\SDK\Test\MockShop; @@ -107,6 +108,55 @@ public function testUninstall(): void static::assertInstanceOf(ShopDeletedEvent::class, $this->events[1]); } + public function testUninstallKeepsShopWhenKeepUserDataIsTrue(): void + { + $this->shopRepository->createShop(new MockShop('123', 'https://foo.com', '1234567890')); + + $body = (string) \json_encode(['data' => ['payload' => ['keepUserData' => true]]], JSON_THROW_ON_ERROR); + $response = $this->appLifecycle->delete(new Request("POST", '/?shop-id=123', [], $body)); + static::assertSame(204, $response->getStatusCode()); + + static::assertNotNull($this->shopRepository->getShopFromId('123')); + + static::assertCount(2, $this->events); + static::assertInstanceOf(BeforeShopDeletionEvent::class, $this->events[0]); + static::assertTrue($this->events[0]->keepUserData()); + static::assertInstanceOf(ShopDeletedEvent::class, $this->events[1]); + static::assertTrue($this->events[1]->keepUserData()); + } + + public function testUninstallDeletesShopWhenKeepUserDataIsFalse(): void + { + $this->shopRepository->createShop(new MockShop('123', 'https://foo.com', '1234567890')); + + $body = (string) \json_encode(['data' => ['payload' => ['keepUserData' => false]]], JSON_THROW_ON_ERROR); + $response = $this->appLifecycle->delete(new Request("POST", '/?shop-id=123', [], $body)); + static::assertSame(204, $response->getStatusCode()); + + static::assertNull($this->shopRepository->getShopFromId('123')); + + static::assertCount(2, $this->events); + static::assertInstanceOf(BeforeShopDeletionEvent::class, $this->events[0]); + static::assertFalse($this->events[0]->keepUserData()); + static::assertInstanceOf(ShopDeletedEvent::class, $this->events[1]); + static::assertFalse($this->events[1]->keepUserData()); + } + + public function testUninstallThrowsOnMalformedBodyAndKeepsShop(): void + { + $this->shopRepository->createShop(new MockShop('123', 'https://foo.com', '1234567890')); + + try { + $this->appLifecycle->delete(new Request("POST", '/?shop-id=123', [], 'not-json')); + static::fail('Expected a MalformedWebhookBodyException on a corrupt body'); + } catch (MalformedWebhookBodyException) { + // expected: a corrupt body must not be treated as keepUserData=false + } + + static::assertNotNull($this->shopRepository->getShopFromId('123')); + static::assertCount(0, $this->events); + } + public function testUninstallNotExisting(): void { $response = $this->appLifecycle->delete(new Request("POST", '/?shop-id=123', [], '{}')); @@ -142,7 +192,7 @@ public function testUninstallLogs(): void $logger ->expects(static::once()) ->method('info') - ->with('Shop uninstalled', ['shop-id' => '123', 'shop-url' => 'https://foo.com']); + ->with('Shop uninstalled', ['shop-id' => '123', 'shop-url' => 'https://foo.com', 'keep-user-data' => false]); $appLifeCycle = new AppLifecycle( $this->createMock(RegistrationService::class),