Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/Lifecycle/PluginDataRemover.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace Swag\AgenticCommerce\Lifecycle;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Log\Package;
use Swag\AgenticCommerce\SwagAgenticCommerce;
use Swag\AgenticCommerce\Ucp\Checkout\DoctrineDbalCheckoutCompletionStore;
use Swag\AgenticCommerce\Ucp\Config\DoctrineDbalUcpConfigRepository;
use Swag\AgenticCommerce\Ucp\Config\UcpConfigService;
use Swag\AgenticCommerce\Ucp\Identity\DoctrineDbalUcpOAuthStore;
use Swag\AgenticCommerce\Ucp\Onboarding\OnboardingDismissal;

/**
* Removes what this plugin owns when a merchant uninstalls without keeping data.
*
* Deliberately narrow. Several things the plugin creates are shared with, or
* depended on by, something that outlives it, and dropping them would break a
* shop rather than clean it:
*
* - `sales_channel_tracking_order` / `_customer` ship natively in Shopware
* 6.7.10+; the plugin's migration only creates them where core has not.
* - The Agentic Commerce `sales_channel_type` row shares its UUID with core, so
* a merchant's feed channels survive the plugin being removed. Dropping it
* would orphan them.
* - `product_export.provider` is a column on a core table.
* - The `ucp_*` tables belong to the SDK bundle, which another consumer could
* in principle also install.
*
* What is left behind is therefore inert: rows in tables that something else
* owns. What is removed is everything that carries this plugin's own prefix,
* plus the per-user onboarding flag it writes into core's `user_config`.
*
* @internal
*/
#[Package('framework')]
final class PluginDataRemover
{
/**
* @var list<string>
*/
public const PLUGIN_TABLES = [
DoctrineDbalUcpOAuthStore::ACCESS_TOKEN_TABLE,
DoctrineDbalUcpOAuthStore::REFRESH_TOKEN_TABLE,
DoctrineDbalUcpOAuthStore::CODE_TABLE,
DoctrineDbalCheckoutCompletionStore::TABLE,
DoctrineDbalUcpConfigRepository::TABLE,
];

/**
* @var list<string>
*/
public const CONFIG_DOMAINS = [
UcpConfigService::DOMAIN,
SwagAgenticCommerce::OPEN_AI_PRODUCT_EXPORT_CONFIG_DOMAIN,
SwagAgenticCommerce::GOOGLE_PRODUCT_EXPORT_CONFIG_DOMAIN,
];

public static function removeAll(Connection $connection): void
{
foreach (self::PLUGIN_TABLES as $table) {
$connection->executeStatement(\sprintf('DROP TABLE IF EXISTS `%s`', $table));
}

foreach (self::CONFIG_DOMAINS as $domain) {
$connection->executeStatement(
'DELETE FROM `system_config` WHERE `configuration_key` LIKE :domain',
['domain' => $domain.'%'],
);
}

OnboardingDismissal::reset($connection);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ class UcpAdminApiService extends ApiService {
return this.httpClient.post(this.basePath(salesChannelId, '/profile-preview'), payload, this.options());
}

// Applies one exposure payload to several channels; responds 200 with a
// per-channel outcome even when some of them failed.
bulkEnable(payload) {
return this.httpClient.post('/_admin/ucp/onboarding/bulk-enable', payload, this.options());
}

// Creates one agentic feed channel per { storefrontSalesChannelId, provider }
// pair; responds 200 with a per-pair outcome even when some of them failed.
createFeedChannels(payload) {
return this.httpClient.post('/_admin/ucp/onboarding/feed-channels', payload, this.options());
}

// The whole settings page in one read: status, the three setup steps, and
// every offerable channel with its outstanding findings.
getReadiness() {
return this.httpClient.get('/_admin/ucp/onboarding/readiness', this.options());
}

basePath(salesChannelId, suffix = '') {
return `/_admin/ucp/sales-channels/${encodeURIComponent(salesChannelId)}${suffix}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ Shopware.Service('privileges').addPrivilegeMappingEntry({
editor: {
privileges: [
'system_config:update',
'system_config:create',
// Creating agentic feed channels from the settings wizard.
'sales_channel:create',
'sales_channel:update',
'product_export:create',
'product_export:read',
'product_stream:create',
'product_stream:read',
],
dependencies: [
'ucp.viewer',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Translates outside a component.
*
* `$t` is installed on the Vue app's `globalProperties` (Vue 3) or on the root
* instance itself (Vue 2), and code running in a router hook has neither `this`
* nor a component context. Falling back to the key keeps a missing translator
* from throwing inside a navigation guard, where it would surface as nothing at
* all.
*/
export function translateAdmin(key, params = undefined) {
const root = Shopware.Application?.getApplicationRoot?.();

if (!root) {
return key;
}

const translate = root.config?.globalProperties?.$t
?? root.config?.globalProperties?.$tc
?? root.$t
?? root.$tc;

if (typeof translate !== 'function') {
return key;
}

try {
const translated = params === undefined ? translate.call(root, key) : translate.call(root, key, params);

return typeof translated === 'string' && translated !== '' ? translated : key;
} catch {
return key;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { readAdminStore } from './admin-store';

/**
* Raises an administration notification from outside a component.
*
* The notification mixin is not available in a router hook, and the store moved
* from a Vuex module to a Pinia store across the supported lanes: Pinia exposes
* `createNotification` on the store, Vuex only through a dispatch.
*/
export function createAdminNotification(notification) {
const store = readAdminStore('notification');
if (typeof store?.createNotification === 'function') {
return store.createNotification(notification);
}

try {
if (typeof Shopware.State?.dispatch === 'function') {
return Shopware.State.dispatch('notification/createNotification', notification);
}
} catch {
// Nothing can raise it; the banners remain the way in.
}

return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { readAdminStore } from './admin-store';

/**
* Reads the logged-in admin user, wherever this lane keeps the session store.
*
* An unreadable session is reported as "nobody logged in" rather than as an
* exception; see admin-store.js for why the read has to be guarded at all.
*/

export function adminSession() {
return readAdminStore('session');
}

export function currentAdminUserId() {
return adminSession()?.currentUser?.id ?? null;
}

export function isAdminLoggedIn() {
return Boolean(adminSession()?.currentUser);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Reads an administration store across the Vuex and Pinia registries.
*
* `Shopware.Store.get(id)` THROWS for an id it does not know rather than
* returning undefined, so `Shopware.Store?.get?.(id) ?? Shopware.State?.get?.(id)`
* does not fall through on a lane that has the Pinia registry but keeps that
* store in Vuex: it throws out of the caller, and inside a router hook or a
* module registration that surfaces as nothing at all. Every read is guarded
* individually and an unreadable store is reported as null.
*/
export function readAdminStore(id) {
const readers = [
() => Shopware.Store?.get?.(id),
() => Shopware.State?.get?.(id),
];

for (const read of readers) {
try {
const store = read();
if (store) {
return store;
}
} catch {
// Registry without this id on this lane; try the next one.
}
}

return null;
}
Loading
Loading