diff --git a/Api/WebhookManagementInterface.php b/Api/WebhookManagementInterface.php
new file mode 100644
index 0000000..3de022b
--- /dev/null
+++ b/Api/WebhookManagementInterface.php
@@ -0,0 +1,14 @@
+registry = $registry;
+ $this->readService = $readService;
+
+ parent::__construct($context, $data);
+ }
+
+ /**
+ * @return \Magento\Catalog\Model\Product|null
+ */
+ public function getProduct()
+ {
+ return $this->registry->registry('current_product');
+ }
+
+ public function getProductJsonLd(): ?array
+ {
+ $product = $this->getProduct();
+ if (!$product) {
+ return null;
+ }
+
+ return $this->readService->getJsonLdProduct($product);
+ }
+
+ public function getReviewsJsonLd(): ?array
+ {
+ $product = $this->getProduct();
+ if (!$product) {
+ return null;
+ }
+
+ return $this->readService->getJsonLdProductReviews($product);
+ }
+}
diff --git a/Console/Command/DeleteAllHooks.php b/Console/Command/DeleteAllHooks.php
new file mode 100644
index 0000000..91e70be
--- /dev/null
+++ b/Console/Command/DeleteAllHooks.php
@@ -0,0 +1,39 @@
+repository = $repository;
+
+ parent::__construct($name);
+ }
+
+ protected function configure(): void
+ {
+ $this->setName('lipscore:hooks:delete')
+ ->setDescription('Deletes Lipscore API hooks');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ $this->repository->deleteHooksForAllKeys();
+
+ $output->writeln("Lipscore Hooks Delete finished");
+
+ return 0;
+ }
+}
diff --git a/Console/Command/ProcessQueue.php b/Console/Command/ProcessQueue.php
new file mode 100644
index 0000000..7a40c11
--- /dev/null
+++ b/Console/Command/ProcessQueue.php
@@ -0,0 +1,44 @@
+action = $action;
+
+ parent::__construct($name);
+ }
+
+ protected function configure(): void
+ {
+ $this->setName('lipscore:queue')
+ ->setDescription('Asks for reviews data if product is present in queue table');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ try {
+ $this->action->execute();
+ $output->writeln('Lipscore Queue Finished');
+
+ return Command::SUCCESS;
+ } catch (\Throwable $e) {
+ $output->writeln('Lipscore Queue Failed');
+
+ return Command::FAILURE;
+ }
+ }
+}
diff --git a/Console/Command/SyncHooks.php b/Console/Command/SyncHooks.php
new file mode 100644
index 0000000..d0588c5
--- /dev/null
+++ b/Console/Command/SyncHooks.php
@@ -0,0 +1,38 @@
+repository = $repository;
+
+ parent::__construct($name);
+ }
+
+ protected function configure(): void
+ {
+ $this->setName('lipscore:hooks:sync')
+ ->setDescription('Asks Lipscore API for product refresh');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ $this->repository->syncKeys();
+ $output->writeln("Lipscore Sync Finished");
+
+ return 0;
+ }
+}
diff --git a/Cron/ProcessQueue.php b/Cron/ProcessQueue.php
new file mode 100644
index 0000000..5b4811c
--- /dev/null
+++ b/Cron/ProcessQueue.php
@@ -0,0 +1,32 @@
+action = $action;
+ $this->queueRepository = $queueRepository;
+ }
+
+ public function execute(Schedule $schedule): void
+ {
+ $this->action->execute();
+ }
+
+ public function cleanup(Schedule $schedule): void
+ {
+ $this->queueRepository->cleanup();
+ }
+}
diff --git a/Exception/ApiException.php b/Exception/ApiException.php
new file mode 100644
index 0000000..97b3bd3
--- /dev/null
+++ b/Exception/ApiException.php
@@ -0,0 +1,10 @@
+apiKeyRepository->collectAllKeys(); // [id => apiKey]
+
+ try {
+ $apiKeys = $this->apiKeyRepository->buildKeyContextMap($keys);
+ if (!$apiKeys) {
+ $this->logger->warning('Lipscore: no API keys configured');
+ return;
+ }
+
+ $totalBatches = 0;
+ $totalItems = 0;
+
+ foreach ($apiKeys as $context) {
+ [$batches, $items] = $this->processForKey($context);
+ $totalBatches += $batches;
+ $totalItems += $items;
+ }
+
+ $this->logger->info('Lipscore queue complete', [
+ 'keys' => count($apiKeys),
+ 'batches' => $totalBatches,
+ 'items' => $totalItems,
+ 'duration_s' => round(microtime(true) - $start, 2),
+ ]);
+
+ } catch (\Throwable $e) {
+ $this->logger->error('Lipscore queue cron error', [
+ 'error_class' => get_class($e),
+ 'message' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ private function processForKey(array $keyContext): array
+ {
+ $keyId = $this->apiKeyRepository->getIdByKey($keyContext['api_key']);
+ if (!$keyId) {
+ return [0, 0];
+ }
+
+ $keyContext['key_id'] = $keyId;
+
+ $batches = 0;
+ $total = 0;
+
+ do {
+ $rows = $this->queueRepository->dequeue($keyId, self::BATCH_SIZE);
+ if (!$rows) {
+ break;
+ }
+
+ $productIds = array_map(
+ static fn(array $row): int => (int)$row['product_id'],
+ $rows
+ );
+
+ $this->logger->debug('Lipscore queue batch', [
+ 'key_id' => $keyId,
+ 'product_ids' => $productIds,
+ ]);
+
+ try {
+ $result = $this->readService->fetchAndSaveBatch($productIds, $keyContext);
+ $saved = $result['saved'] ?? [];
+
+ foreach ($rows as $row) {
+ $queueId = (int)$row['queue_id'];
+ $productId = (int)$row['product_id'];
+ $success = in_array($productId, $saved, true);
+
+ $this->queueRepository->markProcessed($queueId, $success);
+
+ if (!$success) {
+ $this->logger->warning('Lipscore: no data for product', [
+ 'product_id' => $productId,
+ ]);
+ }
+
+ $total++;
+ }
+
+ } catch (\Throwable $e) {
+ foreach ($rows as $row) {
+ $this->queueRepository->markProcessed(
+ (int)$row['queue_id'],
+ false
+ );
+ }
+
+ $this->logger->error('Lipscore batch failed', [
+ 'api_key' => substr($keyContext['api_key'], 0, 4) . '****',
+ 'error_class' => get_class($e),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
+ $batches++;
+ usleep(100000); // 100ms throttle
+
+ } while ($batches < self::MAX_BATCHES);
+
+ return [$batches, $total];
+ }
+}
diff --git a/Model/Api/HookRequest.php b/Model/Api/HookRequest.php
new file mode 100755
index 0000000..460de25
--- /dev/null
+++ b/Model/Api/HookRequest.php
@@ -0,0 +1,164 @@
+client = $client;
+ }
+
+ public function registerAllWebhooks(string $apiKey, $context): array
+ {
+ $results = ['registered' => [], 'failed' => []];
+
+ foreach (self::WEBHOOK_EVENTS as $event) {
+ try {
+ $id = $this->registerWebhook($apiKey, $event, $context);
+ $results['registered'][] = ['event' => $event, 'id' => $id];
+ } catch (\Throwable $e) {
+ $results['failed'][] = ['event' => $event, 'error' => $e->getMessage()];
+ }
+ }
+
+ return $results;
+ }
+
+ public function deregisterSelectedWebhooks($hookIds, $context): array
+ {
+ $results = ['deregistered' => [], 'failed' => []];
+ if (!is_array($hookIds)) {
+ return $results;
+ }
+
+ foreach ($hookIds as $hookId) {
+ try {
+ $this->deleteWebhook($hookId, $context);
+ $results['deregistered'][] = $hookId;
+ } catch (\Throwable $e) {
+ $results['failed'][] = ['id' => $hookId, 'error' => $e->getMessage()];
+ }
+ }
+
+ return $results;
+ }
+
+ public function deregisterAllWebhooks(string $apiKey, $context): array
+ {
+ $results = ['deregistered' => [], 'failed' => []];
+
+ foreach ($this->fetchAllWebhooks($apiKey, $context) as $hook) {
+ $hookId = (string)($hook['id'] ?? 0);
+
+ if ($hookId == 0) {
+ continue;
+ }
+
+ try {
+ $this->deleteWebhook($apiKey, $hookId, $context);
+ $results['deregistered'][] = $hookId;
+ } catch (\Throwable $e) {
+ $results['failed'][] = ['id' => $hookId, 'error' => $e->getMessage()];
+ }
+ }
+
+ return $results;
+ }
+
+ private function registerWebhook(string $apiKey, string $event, $context): string
+ {
+ $url = $this->buildUrl($context['api_url'], '/hooks', $apiKey);
+
+ $response = $this->client->post($url, [
+ 'headers' => $this->defaultHeaders($context['api_secret']),
+ 'json' => [
+ 'event' => $event,
+ 'target_url' => rtrim($context['base_url'], '/') . self::WEBHOOK_PATH,
+ ],
+ ]);
+
+ $status = $response->getStatusCode();
+
+ if (!in_array($status, [201, 409], true)) {
+ throw new ApiException(
+ "Webhook registration failed for [{$event}], HTTP {$status}"
+ );
+ }
+
+ $result = $this->decodeResponse((string)$response->getBody());
+ if (isset($result['id'])) {
+ return (string)$result['id'];
+ }
+
+ throw new ApiException(
+ "Webhook registration failed for [{$event}], no ID returned"
+ );
+ }
+
+ private function fetchAllWebhooks(string $apiKey, $context): array
+ {
+ $url = $this->buildUrl($context['api_url'], '/hooks', $apiKey);
+ $response = $this->client->get($url, ['headers' => $this->defaultHeaders($context['api_secret'])]);
+ $status = $response->getStatusCode();
+
+ if ($status !== 200) {
+ throw new ApiException("Fetching webhooks failed, HTTP {$status}");
+ }
+
+ return $this->decodeResponse((string)$response->getBody());
+ }
+
+ private function deleteWebhook(string $hookId, $context): void
+ {
+ $url = $this->buildUrl($context['api_url'], "/hooks/{$hookId}", $context['api_key']);
+ $response = $this->client->delete($url, ['headers' => $this->defaultHeaders($context['api_secret'])]);
+ $status = $response->getStatusCode();
+
+ if (!in_array($status, [200, 204], true)) {
+ throw new ApiException(
+ "Webhook deletion failed for hook ID [{$hookId}], HTTP {$status}"
+ );
+ }
+ }
+
+ private function buildUrl(string $baseUrl, string $path, string $apiKey): string
+ {
+ return rtrim($baseUrl, '/') . $path . '?api_key=' . urlencode($apiKey);
+ }
+
+ private function defaultHeaders(string $apiSecret): array
+ {
+ return [
+ 'X-Authorization' => $apiSecret,
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+ ];
+ }
+
+ private function decodeResponse(string $body): array
+ {
+ $data = json_decode($body, true);
+
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new ApiException('Invalid JSON response: ' . json_last_error_msg());
+ }
+
+ return is_array($data) ? $data : [];
+ }
+}
diff --git a/Model/Api/ProductRequest.php b/Model/Api/ProductRequest.php
new file mode 100644
index 0000000..ec3b2b4
--- /dev/null
+++ b/Model/Api/ProductRequest.php
@@ -0,0 +1,83 @@
+client = $client;
+ $this->productResolver = $productResolver;
+ }
+
+ public function fetchProductsBatch(array $magentoProductIds, $context): array
+ {
+ $map = $this->productResolver->getMagentoToLipscoreMap($magentoProductIds);
+
+ if (!$map) {
+ return [];
+ }
+
+ $apiData = $this->fetchFromLipscoreBatch(array_values($map), $context);
+
+ return $this->mapApiDataToMagento($apiData, $map);
+ }
+
+ private function fetchFromLipscoreBatch(array $internalIds, $context): array
+ {
+ $url = rtrim($context['api_url'], '/') . '/products?' . http_build_query([
+ 'api_key' => $context['api_key'],
+ 'per_page' => self::PRODUCTS_PER_PAGE,
+ 'fields' => self::PRODUCT_FIELDS,
+ ]) . '&' . implode('&', array_map(
+ static fn(string $id) => 'internal_id[]=' . urlencode($id),
+ $internalIds
+ ));
+
+ $response = $this->client->get($url, [
+ 'headers' => [
+ 'X-Authorization' => $context['api_secret'],
+ 'Accept' => 'application/json',
+ ],
+ 'timeout' => 0.5,
+ ]);
+
+ if ($response->getStatusCode() !== 200) {
+ return [];
+ }
+
+ return json_decode((string)$response->getBody(), true) ?: [];
+ }
+
+ private function mapApiDataToMagento(array $apiData, array $magentoToLipscore): array
+ {
+ $lipscoreToMagento = array_flip($magentoToLipscore);
+
+ $result = [];
+ foreach ($apiData as $product) {
+ $internalId = $product['internal_id'] ?? null;
+
+ if ($internalId === null || !isset($lipscoreToMagento[$internalId])) {
+ continue;
+ }
+
+ $product['magento_product_id'] = (int)$lipscoreToMagento[$internalId];
+ $result[] = $product;
+ }
+
+ return $result;
+ }
+}
diff --git a/Model/Api/Request.php b/Model/Api/Request.php
index 01d2ef7..ce5447c 100755
--- a/Model/Api/Request.php
+++ b/Model/Api/Request.php
@@ -19,8 +19,6 @@ class Request
protected $client;
- protected $storeId = null;
-
public function __construct(
Config $config
) {
@@ -45,7 +43,7 @@ public function __construct(
}
}
- public function send($data, $path = null)
+ public function send($data, $path = null, $storeId = null)
{
if (!$path) {
$path = 'purchases';
@@ -55,9 +53,9 @@ public function send($data, $path = null)
$timeout = getenv('REMINDER_TIMEOUT');
$this->timeout = $timeout ?: static::REMINDER_TIMEOUT;
- $apiKey = $this->config->getApiKey($this->storeId);
- $secret = $this->config->getApiSecret($this->storeId);
- $apiUrl = $this->config->getApiUrl($this->storeId);
+ $apiKey = $this->config->getApiKey($storeId);
+ $secret = $this->config->getApiSecret($storeId);
+ $apiUrl = $this->config->getApiUrl($storeId);
$headers = [
'X-Authorization' => strval($secret),
'Content-Type' => 'application/json',
@@ -85,11 +83,6 @@ public function send($data, $path = null)
return $result;
}
- public function setStoreId($storeId)
- {
- $this->storeId = $storeId;
- }
-
public function responseMsg()
{
return $this->response ? $this->response->__toString() : '';
diff --git a/Model/ApiKeyRepository.php b/Model/ApiKeyRepository.php
new file mode 100644
index 0000000..faf4150
--- /dev/null
+++ b/Model/ApiKeyRepository.php
@@ -0,0 +1,292 @@
+resource = $resource;
+ $this->storeManager = $storeManager;
+ $this->config = $config;
+ $this->request = $request;
+ $this->logger = $logger;
+ }
+
+ private function getConnection(): AdapterInterface
+ {
+ return $this->resource->getConnection();
+ }
+
+ private function getTable(): string
+ {
+ return $this->resource->getTableName(self::TABLE_NAME);
+ }
+
+ public function getAllKeys(): array
+ {
+ return $this->getConnection()->fetchPairs(
+ sprintf('SELECT api_key_id, `key` FROM %s', $this->getTable())
+ );
+ }
+
+ public function getIdByKey(?string $key): ?int
+ {
+ if (!$key) {
+ return null;
+ }
+
+ $id = $this->getConnection()->fetchOne(
+ sprintf('SELECT api_key_id FROM %s WHERE `key` = ? LIMIT 1', $this->getTable()),
+ [$key]
+ );
+
+ return $id !== false ? (int)$id : null;
+ }
+
+ public function deleteHooksForAllKeys(): void
+ {
+ $activeMap = $this->buildKeyContextMap($this->collectAllKeys());
+ foreach ($activeMap as $apiKey => $context) {
+ $this->request->deregisterAllWebhooks($apiKey, $context);
+ }
+ }
+
+ public function syncKeys(): void
+ {
+ $connection = $this->getConnection();
+ $table = $this->getTable();
+
+ $activeMap = $this->buildKeyContextMap($this->collectAllKeys());
+ $existing = $connection->fetchPairs(
+ sprintf('SELECT `key`, api_key_id FROM %s', $table)
+ );
+
+ $toAdd = array_diff(array_keys($activeMap), array_keys($existing));
+ $toRemove = array_diff(array_keys($existing), array_keys($activeMap));
+
+ foreach ($toAdd as $apiKey) {
+ $context = $activeMap[$apiKey];
+ $this->registerKey($apiKey, $context);
+ }
+
+ foreach ($toRemove as $apiKey) {
+ $context = $activeMap[$apiKey];
+ $this->removeKey($apiKey, $context);
+ }
+ }
+
+ public function collectAllKeys(): array
+ {
+ $keys = [];
+
+ // Default scope
+ $defaultKey = $this->config->getApiKeyForScope('default');
+ if ($this->config->isWebhookEnabledForScope('default') && $defaultKey) {
+ $keys[] = [
+ 'scope' => 'default',
+ 'scope_id' => 0,
+ 'api_key' => trim($defaultKey),
+ ];
+ }
+
+ // Website scope
+ foreach ($this->storeManager->getWebsites() as $website) {
+ $websiteId = (int)$website->getId();
+ $key = $this->config->getApiKeyForScope(ScopeInterface::SCOPE_WEBSITE, $websiteId);
+
+ if ($this->config->isWebhookEnabledForScope(ScopeInterface::SCOPE_WEBSITE, $websiteId) && $key) {
+ $keys[] = [
+ 'scope' => 'website',
+ 'scope_id' => $websiteId,
+ 'api_key' => trim($key),
+ ];
+ }
+ }
+
+ // Store scope
+ foreach ($this->storeManager->getStores() as $store) {
+ $storeId = (int)$store->getId();
+ $key = $this->config->getApiKeyForScope(ScopeInterface::SCOPE_STORE, $storeId);
+
+ if ($this->config->isWebhookEnabledForScope(ScopeInterface::SCOPE_STORE, $storeId) && $key) {
+ $keys[] = [
+ 'scope' => 'store',
+ 'scope_id' => $storeId,
+ 'api_key' => trim($key),
+ ];
+ }
+ }
+
+ return $keys;
+ }
+
+ public function buildKeyContextMap(array $entries): array
+ {
+ $priorityMap = ['default' => 0, 'website' => 1, 'store' => 2];
+ $result = [];
+
+ foreach ($entries as $entry) {
+ $apiKey = $entry['api_key'];
+ $priority = $priorityMap[$entry['scope']] ?? 0;
+
+ if (isset($result[$apiKey]) && $result[$apiKey]['priority'] >= $priority) {
+ continue;
+ }
+
+ $context = $this->resolveContext($entry['scope'], $entry['scope_id']);
+
+ $result[$apiKey] = [
+ 'api_key' => $apiKey,
+ 'base_url' => $context['base_url'],
+ 'api_secret' => $context['api_secret'],
+ 'api_url' => $context['api_url'],
+ 'priority' => $priority,
+ ];
+ }
+
+ return $result;
+ }
+
+ private function resolveContext(string $scope, int $scopeId): array
+ {
+ return match ($scope) {
+ 'store' => $this->resolveStore($scopeId),
+ 'website' => $this->resolveWebsite($scopeId),
+ default => $this->resolveDefault(),
+ };
+ }
+
+ private function resolveStore(int $storeId): array
+ {
+ $store = $this->storeManager->getStore($storeId);
+
+ return [
+ 'base_url' => $store->getBaseUrl(),
+ 'api_secret' => (string)$this->config->getApiSecretForScope(
+ ScopeInterface::SCOPE_STORE,
+ $storeId
+ ),
+ 'api_url' => $this->config->getApiUrl($storeId),
+ ];
+ }
+
+ private function resolveWebsite(int $websiteId): array
+ {
+ $website = $this->storeManager->getWebsite($websiteId);
+ $defaultStore = $website->getDefaultStore();
+
+ if (!$defaultStore) {
+ throw new \RuntimeException("Website {$websiteId} has no default store.");
+ }
+
+ return [
+ 'base_url' => $defaultStore->getBaseUrl(),
+ 'api_secret' => (string)$this->config->getApiSecretForScope(
+ ScopeInterface::SCOPE_WEBSITE,
+ $websiteId
+ ),
+ 'api_url' => $this->config->getApiKeyForScope(
+ ScopeInterface::SCOPE_WEBSITE,
+ $websiteId
+ ),
+ ];
+ }
+
+ private function resolveDefault(): array
+ {
+ $defaultStore = $this->storeManager->getDefaultStoreView();
+
+ return [
+ 'base_url' => $defaultStore->getBaseUrl(),
+ 'api_secret' => (string)$this->config->getApiSecretForScope('default'),
+ 'api_url' => $this->config->getApiKeyForScope(
+ 'default'
+ ),
+ ];
+ }
+
+ private function registerKey(string $apiKey, $context): void
+ {
+ try {
+ $result = $this->request->registerAllWebhooks($apiKey, $context);
+
+ $ids = array_map(function($item) { return $item['id']; }, $result['registered']);
+ $this->getConnection()->insert(
+ $this->getTable(),
+ ['key' => $apiKey, 'external_hook_ids' => $ids ? implode(',', $ids) : '']
+ );
+
+ $this->logger->info('Lipscore webhooks registered', [
+ 'key' => $this->maskKey($apiKey),
+ 'store_url' => $context['base_url'],
+ 'registered' => $result['registered'] ?? 0,
+ 'skipped' => $result['skipped'] ?? 0,
+ 'failed' => $result['failed'] ?? 0,
+ ]);
+
+ } catch (\Throwable $e) {
+ $this->logger->error('Lipscore webhook registration failed', [
+ 'key' => $this->maskKey($apiKey),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ private function removeKey(int $keyId, $context): void
+ {
+ $externalHookIds = $this->getConnection()->fetchOne(
+ $this->getConnection()
+ ->select()
+ ->from($this->getTable(), ['external_hook_ids'])
+ ->where('api_key_id = ?', $keyId)
+ ->limit(1)
+ );
+
+ if ($externalHookIds) {
+ $this->request->deregisterSelectedWebhooks(explode(',', $externalHookIds), $context);
+ }
+
+ $this->getConnection()->delete(
+ $this->getTable(),
+ ['api_key_id = ?' => $keyId]
+ );
+
+ $this->logger->info('Lipscore API key removed', ['key_id' => $keyId]);
+ }
+
+ private function maskKey(string $key): string
+ {
+ if (strlen($key) <= 8) {
+ return '****';
+ }
+
+ return substr($key, 0, 4) . '****' . substr($key, -4);
+ }
+}
diff --git a/Model/Cache/FpcRefresher.php b/Model/Cache/FpcRefresher.php
new file mode 100644
index 0000000..8168dfc
--- /dev/null
+++ b/Model/Cache/FpcRefresher.php
@@ -0,0 +1,115 @@
+productRepository = $productRepository;
+ $this->frontendPool = $frontendPool;
+ $this->cacheTagResolver = $cacheTagResolver;
+ $this->pageCacheConfig = $pageCacheConfig;
+ $this->purgeCache = $purgeCache;
+ $this->logger = $logger;
+ }
+
+ public function refreshProduct(int $productId): void
+ {
+ try {
+ $tags = $this->resolveProductCacheTags($productId);
+
+ $this->logger->info('Flushing FPC for product', [
+ 'product_id' => $productId,
+ 'backend' => $this->isVarnish() ? 'varnish' : 'builtin',
+ 'tags' => $tags,
+ ]);
+
+ if ($this->isVarnish()) {
+ $this->flushVarnish($tags);
+ } else {
+ $this->flushBuiltin($tags);
+ }
+
+ } catch (\Throwable $e) {
+ $this->logger->error('FPC flush failed', [
+ 'product_id' => $productId,
+ 'exception' => $e->getMessage(),
+ ]);
+
+ throw new \RuntimeException(
+ "FPC flush failed for product [{$productId}]: " . $e->getMessage(),
+ (int)$e->getCode(),
+ $e
+ );
+ }
+ }
+
+ private function flushVarnish(array $tags): void
+ {
+ $this->purgeCache->sendPurgeRequest(implode('|', $tags));
+ }
+
+ private function flushBuiltin(array $tags): void
+ {
+ $this->frontendPool
+ ->get(Type::TYPE_IDENTIFIER)
+ ->clean(\Zend_Cache::CLEANING_MODE_MATCHING_TAG, $tags);
+ }
+
+ private function isVarnish(): bool
+ {
+ return $this->pageCacheConfig->getType() === PageCacheConfig::VARNISH;
+ }
+
+ private function resolveProductCacheTags(int $productId): array
+ {
+ try {
+ /** @var Product $product */
+ $product = $this->productRepository->getById($productId, false);
+ $tags = $this->cacheTagResolver->getTags($product);
+
+ if (!empty($tags)) {
+ return array_values($tags);
+ }
+ } catch (\Throwable $e) {
+ $this->logger->warning('Could not resolve cache tags, using fallback', [
+ 'product_id' => $productId,
+ 'exception' => $e->getMessage(),
+ ]);
+ }
+
+ return [self::PRODUCT_TAG_PREFIX . $productId];
+ }
+}
diff --git a/Model/Config.php b/Model/Config.php
index 02ebd62..3c90d29 100755
--- a/Model/Config.php
+++ b/Model/Config.php
@@ -1,16 +1,31 @@
logger = $logger;
$this->scopeConfig = $scopeConfig;
$this->manager = $manager;
+ $this->encryptor = $encryptor;
}
public function getParentSourceId()
@@ -62,22 +77,17 @@ public function isValidApiKey()
public function isLipscoreModuleEnabled($store = null)
{
- try {
- return $this->manager->isEnabled(Module::MODULE_NAME) && $this->isActive($store);
- } catch (\Exception $e) {
- $this->logger->log($e);
- return false;
- }
+ return $this->manager->isEnabled(Module::MODULE_NAME) && $this->isActive($store);
}
public function isLipscoreOutputEnabled()
{
- try {
- return $this->manager->isOutputEnabled(Module::MODULE_NAME) && $this->isActive();
- } catch (\Exception $e) {
- $this->logger->log($e);
- return false;
- }
+ return $this->manager->isOutputEnabled(Module::MODULE_NAME) && $this->isActive();
+ }
+
+ public function isFpcClearEnabled()
+ {
+ return (bool) $this->scopeConfig->isSetFlag(self::XML_PATH_CACHE_FPC_CLEAR);
}
public function getStoreLocale($store = null)
@@ -98,6 +108,15 @@ public function getApiKey($store = null)
);
}
+ public function getApiKeyForScope(string $scope, mixed $scopeId = null): ?string
+ {
+ return $this->scopeConfig->getValue(
+ self::XML_PATH_LIPSCORE_API_KEY,
+ $scope,
+ $scopeId
+ );
+ }
+
public function getAssetsUrl($store = null)
{
return $this->scopeConfig->getValue(
@@ -116,6 +135,15 @@ public function getApiUrl($store = null)
);
}
+ public function getApiUrlForScope(string $scope, mixed $scopeId = null): ?string
+ {
+ return $this->scopeConfig->getValue(
+ self::XML_PATH_LIPSCORE_API_URL,
+ $scope,
+ $scopeId
+ );
+ }
+
public function getApiSecret($store = null)
{
return $this->scopeConfig->getValue(
@@ -125,6 +153,15 @@ public function getApiSecret($store = null)
);
}
+ public function getApiSecretForScope(string $scope, mixed $scopeId = null): ?string
+ {
+ return $this->scopeConfig->getValue(
+ self::XML_PATH_LIPSCORE_API_SECRET,
+ $scope,
+ $scopeId
+ );
+ }
+
public function canShowChildDataInParent($store = null)
{
return $this->scopeConfig->isSetFlag(
@@ -250,4 +287,80 @@ public function isActive($store = null)
$store
);
}
+
+ public function isWebhookEnabled($store = null): bool
+ {
+ return $this->scopeConfig->isSetFlag(
+ self::XML_PATH_WEBHOOK_ENABLED,
+ ScopeInterface::SCOPE_STORE,
+ $store);
+ }
+
+ public function isWebhookEnabledForScope(
+ string $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT,
+ mixed $scopeId = null
+ ): bool
+ {
+ return $this->scopeConfig->isSetFlag(
+ self::XML_PATH_WEBHOOK_ENABLED,
+ $scope,
+ $scopeId
+ );
+ }
+
+ public function isJsonLdEnabled($store = null): bool
+ {
+ return $this->scopeConfig->isSetFlag(
+ self::XML_PATH_JSONLD_ENABLED,
+ ScopeInterface::SCOPE_STORE,
+ $store
+ );
+ }
+
+ public function getWebhookHmacSecret(): string
+ {
+ $value = (string) $this->scopeConfig->getValue(self::XML_PATH_WEBHOOK_SECRET);
+ if ($value) {
+ return (string) $this->encryptor->decrypt($value);
+ }
+
+ return $value;
+ }
+
+ public function getFreshThreshold($store = null): int
+ {
+ $hours = (int) $this->scopeConfig->getValue(
+ self::XML_PATH_FRESH_THRESHOLD,
+ ScopeInterface::SCOPE_STORE,
+ $store
+ );
+ return ($hours > 0 ? $hours : self::DEFAULT_FRESH_HOURS) * 3600;
+ }
+
+ public function getStaleThreshold($store = null): int
+ {
+ $hours = (int) $this->scopeConfig->getValue(
+ self::XML_PATH_STALE_THRESHOLD,
+ ScopeInterface::SCOPE_STORE,
+ $store
+ );
+ return ($hours > 0 ? $hours : self::DEFAULT_STALE_HOURS) * 3600;
+ }
+
+ public function getCacheTtl($store = null): int
+ {
+ $hours = (int) $this->scopeConfig->getValue(
+ self::XML_PATH_CACHE_TTL,
+ ScopeInterface::SCOPE_STORE,
+ $store
+ );
+ return ($hours > 0 ? $hours : self::DEFAULT_TTL_HOURS) * 3600;
+ }
+
+ public function isLogEnabled(): bool
+ {
+ return $this->scopeConfig->isSetFlag(
+ self::XML_PATH_LIPSCORE_LOG_ENABLED
+ );
+ }
}
diff --git a/Model/JsonLd/ProductJsonLdRepository.php b/Model/JsonLd/ProductJsonLdRepository.php
new file mode 100644
index 0000000..e140d6b
--- /dev/null
+++ b/Model/JsonLd/ProductJsonLdRepository.php
@@ -0,0 +1,75 @@
+connection = $resource->getConnection();
+ $this->table = $resource->getTableName('lipscore_product_data');
+ }
+
+ /**
+ * Insert or update from raw Lipscore API response
+ */
+ public function upsert(
+ int $productId,
+ int $keyId,
+ array $apiData
+ ): void {
+ $reviews = $apiData['reviews'] ?? [];
+
+ usort(
+ $reviews,
+ static fn(array $a, array $b): int =>
+ strtotime($b['created_at'] ?? '') <=> strtotime($a['created_at'] ?? '')
+ );
+
+ $data = [
+ 'product_id' => $productId,
+ 'key_id' => $keyId,
+ 'rating_value' => $apiData['rating'] ?? null,
+ 'review_count' => (int) ($apiData['review_count'] ?? 0),
+ 'rating_count' => (int) ($apiData['votes'] ?? 0),
+ 'last_reviews' => json_encode(array_slice($reviews, 0, 10)),
+ ];
+
+ $this->connection->insertOnDuplicate(
+ $this->table,
+ $data,
+ [
+ 'rating_value',
+ 'review_count',
+ 'rating_count',
+ 'last_reviews',
+ 'key_id',
+ 'product_id',
+ 'updated_at'
+ ]
+ );
+ }
+
+ public function getProductData(
+ int $productId,
+ int $keyId
+ ): ?array {
+ $select = $this->connection->select()
+ ->from($this->table)
+ ->where('product_id = ?', $productId)
+ ->where('key_id = ?', $keyId)
+ ->limit(1);
+
+ $row = $this->connection->fetchRow($select);
+
+ return $row ?: null;
+ }
+}
diff --git a/Model/Logger/Logger.php b/Model/Logger/Logger.php
index cbb36bf..9c9fd81 100755
--- a/Model/Logger/Logger.php
+++ b/Model/Logger/Logger.php
@@ -1,7 +1,66 @@
config = $config;
+ parent::__construct($name, $handlers, $processors, $timezone);
+ }
+
+ public function debug($message, array $context = []): void
+ {
+ if (!$this->isLoggingEnabled()) {
+ return;
+ }
+
+ parent::debug($message, $context);
+ }
+
+ public function info($message, array $context = []): void
+ {
+ if (!$this->isLoggingEnabled()) {
+ return;
+ }
+
+ parent::info($message, $context);
+ }
+
+ public function warning($message, array $context = []): void
+ {
+ if (!$this->isLoggingEnabled()) {
+ return;
+ }
+
+ parent::warning($message, $context);
+ }
+
+ public function error($message, array $context = []): void
+ {
+ if (!$this->isLoggingEnabled()) {
+ return;
+ }
+
+ parent::error($message, $context);
+ }
+
+ private function isLoggingEnabled(): bool
+ {
+ return $this->config->isLogEnabled();
+ }
}
diff --git a/Model/Queue.php b/Model/Queue.php
new file mode 100644
index 0000000..ce2ff62
--- /dev/null
+++ b/Model/Queue.php
@@ -0,0 +1,45 @@
+_init(ResourceModel\Queue::class);
+ }
+
+ public function getQueueId(): int
+ {
+ return (int)$this->getData('queue_id');
+ }
+
+ public function getProductId(): int
+ {
+ return (int)$this->getData('product_id');
+ }
+
+ public function getStatus(): string
+ {
+ return (string)$this->getData('status');
+ }
+
+ public function getKeyId(): string
+ {
+ return (int)$this->getData('key_id');
+ }
+
+ public function getRetries(): int
+ {
+ return (int)$this->getData('retries');
+ }
+}
diff --git a/Model/QueueRepository.php b/Model/QueueRepository.php
new file mode 100644
index 0000000..d07145a
--- /dev/null
+++ b/Model/QueueRepository.php
@@ -0,0 +1,167 @@
+resource = $resource;
+ $this->logger = $logger;
+ }
+
+ private function connection(): AdapterInterface
+ {
+ return $this->resource->getConnection();
+ }
+
+ private function table(): string
+ {
+ return $this->resource->getTableName(self::TABLE);
+ }
+
+ public function enqueue(int $productId, ?int $keyId, int $priority = self::PRIORITY_LOW): void
+ {
+ if (!$keyId) {
+ return;
+ }
+
+ $conn = $this->connection();
+ $table = $this->table();
+
+ try {
+ $conn->insertOnDuplicate(
+ $table,
+ [
+ 'product_id' => $productId,
+ 'key_id' => $keyId,
+ 'status' => Queue::STATUS_PENDING,
+ 'priority' => $priority,
+ 'retries' => 0,
+ 'created_at' => gmdate('Y-m-d H:i:s'),
+ ],
+ [] // do nothing on duplicate
+ );
+ } catch (\Throwable $e) {
+ $this->logger->error('Queue enqueue failed', [
+ 'product_id' => $productId,
+ 'key_id' => $keyId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ public function dequeue(?int $keyId, int $batchSize = 20): array
+ {
+ if (!$keyId) {
+ return [];
+ }
+
+ $conn = $this->connection();
+ $table = $this->table();
+
+ $conn->beginTransaction();
+
+ try {
+ $select = $conn->select()
+ ->from($table)
+ ->where('status = ?', Queue::STATUS_PENDING)
+ ->where('retries < ?', self::MAX_RETRIES)
+ ->where('key_id = ?', $keyId)
+ ->order('priority DESC')
+ ->order('created_at ASC')
+ ->limit($batchSize)
+ ->forUpdate();
+
+ $rows = $conn->fetchAll($select);
+
+ if (!$rows) {
+ $conn->commit();
+ return [];
+ }
+
+ $ids = array_column($rows, 'queue_id');
+
+ $conn->update(
+ $table,
+ ['status' => Queue::STATUS_PROCESSING],
+ ['queue_id IN (?)' => $ids]
+ );
+
+ $conn->commit();
+
+ return $rows;
+
+ } catch (\Throwable $e) {
+ $conn->rollBack();
+ $this->logger->error('Queue dequeue failed', [
+ 'key_id' => $keyId,
+ 'error' => $e->getMessage(),
+ ]);
+ return [];
+ }
+ }
+
+ public function markProcessed(int $queueId, bool $success): void
+ {
+ $conn = $this->connection();
+ $table = $this->table();
+
+ if ($success) {
+ $conn->delete(
+ $table,
+ ['status' => Queue::STATUS_DONE],
+ ['queue_id = ?' => $queueId]
+ );
+ return;
+ }
+
+ $conn->query(
+ sprintf(
+ 'UPDATE %s
+ SET retries = retries + 1,
+ status = IF(retries + 1 >= ?, ?, ?)
+ WHERE queue_id = ?',
+ $table
+ ),
+ [
+ self::MAX_RETRIES,
+ Queue::STATUS_FAILED,
+ Queue::STATUS_PENDING,
+ $queueId
+ ]
+ );
+ }
+
+ public function cleanup(int $days = 3): int
+ {
+ $conn = $this->connection();
+ $table = $this->table();
+
+ return $conn->delete(
+ $table,
+ [
+ 'status IN (?)' => [Queue::STATUS_DONE, Queue::STATUS_FAILED],
+ 'updated_at < DATE_SUB(NOW(), INTERVAL ? DAY)' => $days,
+ ]
+ );
+ }
+}
diff --git a/Model/Reminder.php b/Model/Reminder.php
index a80004e..628cf33 100755
--- a/Model/Reminder.php
+++ b/Model/Reminder.php
@@ -3,6 +3,7 @@
namespace Lipscore\RatingsReviews\Model;
use Lipscore\RatingsReviews\Model\Api\Request;
+use Lipscore\RatingsReviews\Helper\Reminder as Helper;
class Reminder
{
@@ -13,7 +14,7 @@ class Reminder
protected $sender;
public function __construct(
- \Lipscore\RatingsReviews\Helper\Reminder $helper,
+ Helper $helper,
Request $sender,
Config $config
) {
@@ -29,8 +30,7 @@ public function send($order)
}
$data = $this->helper->data($order);
- $this->sender->setStoreId($order->getStoreId());
- return $this->sender->send($data);
+ return $this->sender->send($data, 'purchases', $order->getStoreId());
}
}
diff --git a/Model/ResourceModel/Queue.php b/Model/ResourceModel/Queue.php
new file mode 100644
index 0000000..d2658c5
--- /dev/null
+++ b/Model/ResourceModel/Queue.php
@@ -0,0 +1,15 @@
+_init('lipscore_queue', 'queue_id');
+ }
+}
diff --git a/Model/Service/JsonLd/JsonLdBuilder.php b/Model/Service/JsonLd/JsonLdBuilder.php
new file mode 100644
index 0000000..6180b2f
--- /dev/null
+++ b/Model/Service/JsonLd/JsonLdBuilder.php
@@ -0,0 +1,290 @@
+ 'url',
+ 'description' => 'description',
+ 'image' => 'image_url',
+ 'sku' => 'sku',
+ 'mpn' => 'mpn',
+ ];
+
+ private const RATING_BEST = 5;
+ private const RATING_WORST = 1;
+ private const MAX_DESC_LENGTH = 200;
+ private const VALID_GTIN_LENGTHS = [8, 12, 13, 14];
+
+ protected $logger;
+
+ protected $helper;
+
+ public function __construct(
+ Logger $logger,
+ Product $helper
+ ) {
+ $this->logger = $logger;
+ $this->helper = $helper;
+ }
+
+ public function build(ProductInterface $product, array $ratings): array
+ {
+ $stale = (bool) ($ratings['stale'] ?? false);
+ $data = $this->getProductData($product);
+
+ $schema = $this->buildProductData($data, $ratings);
+ if ($stale) {
+ $schema['_stale'] = true;
+ }
+
+ return $schema;
+ }
+
+ public function buildReviews(ProductInterface $product, array $ratings): array
+ {
+ $reviews = $this->normalizeReviews($ratings);
+
+ if (empty($reviews)) {
+ return [];
+ }
+
+ $data = $this->getProductData($product);
+ $url = $data['url'] ?? null;
+ $schema = $this->buildProductBase($data);
+
+ $schema['review'] = $this->mapReviews($reviews, $url);
+
+ return $schema;
+ }
+
+ private function buildProductBase(array $data): array
+ {
+ $schema = [
+ '@context' => 'http://schema.org',
+ '@type' => 'Product',
+ ];
+
+ if (!empty($data['url'])) {
+ $schema['@id'] = $data['url'] . '#this';
+ }
+
+ if (!empty($data['name'])) {
+ $schema['name'] = $data['name'];
+ }
+
+ return $schema;
+ }
+
+ private function buildProductData(array $data, array $ratings): array
+ {
+ $schema = $this->buildProductBase($data);
+
+ // simple scalar fields
+ foreach (self::FIELD_MAPPING as $schemaField => $helperKey) {
+ if (!empty($data[$helperKey])) {
+ $value = $data[$helperKey];
+
+ if ($schemaField === 'description') {
+ $value = $this->truncateDescription($value);
+ }
+
+ $schema[$schemaField] = $value;
+ }
+ }
+
+ // brand as typed object
+ if (!empty($data['brand'])) {
+ $schema['brand'] = [
+ '@type' => 'Brand',
+ 'name' => $data['brand'],
+ ];
+ }
+
+ // category
+ if (!empty($data['category'])) {
+ $schema['category'] = $data['category'];
+ }
+
+ // aggregateRating
+ $aggregateRating = $this->buildAggregateRating($ratings);
+ if ($aggregateRating !== null) {
+ $schema['aggregateRating'] = $aggregateRating;
+ }
+
+ // offers
+ if (!empty($data['price']) && !empty($data['currency'])) {
+ $schema['offers'] = $this->buildOffer($data);
+ }
+
+ $sku = $this->firstValue($data['sku'] ?? '');
+ $mpn = $this->firstValue($data['mpn'] ?? '');
+ $validGtins = $this->filterGtins($data['gtin'] ?? []);
+
+ $this->addGtins($schema, $validGtins);
+ $this->addProductId($schema, $sku, $mpn, $validGtins);
+
+ return $schema;
+ }
+
+ private function buildAggregateRating(array $ratings): ?array
+ {
+ $ratingCount = (int) ($ratings['votes_count'] ?? $ratings['review_count'] ?? 0);
+
+ if ($ratingCount <= 0) {
+ return null;
+ }
+
+ $data = [
+ '@type' => 'AggregateRating',
+ 'ratingValue' => $ratings['rating_value'] ?? null,
+ 'bestRating' => self::RATING_BEST,
+ 'worstRating' => self::RATING_WORST,
+ ];
+
+ $reviewCount = (int) ($ratings['review_count'] ?? 0);
+ if ($reviewCount > 0) {
+ $data['reviewCount'] = $reviewCount;
+ }
+ if ($ratingCount > 0) {
+ $data['ratingCount'] = $ratingCount;
+ }
+
+ return $data;
+ }
+
+ private function buildOffer(array $data): array
+ {
+ $offer = [
+ '@type' => 'Offer',
+ 'price' => $this->normalizePrice($data['price']),
+ 'priceCurrency' => $data['currency'],
+ 'url' => $data['url'] ?? null,
+ ];
+
+ if (isset($data['in_stock'])) {
+ $offer['availability'] = $data['in_stock']
+ ? 'http://schema.org/InStock'
+ : 'http://schema.org/OutOfStock';
+ }
+
+ return $offer;
+ }
+
+ private function mapReviews(array $reviews, ?string $productUrl): array
+ {
+ return array_map(function (array $r) use ($productUrl): array {
+ $isBasedOn = $r['origin']['url'] ?? $productUrl;
+
+ $entry = [
+ '@type' => 'Review',
+ 'author' => [
+ '@type' => 'Person',
+ 'name' => $r['user']['name'] ?? $r['user'] ?? $r['author'] ?? null,
+ ],
+ 'datePublished' => $r['created_at'] ?? $r['datePublished'] ?? null,
+ 'reviewBody' => $r['text'] ?? $r['reviewBody'] ?? null,
+ 'isBasedOn' => $isBasedOn,
+ ];
+
+ // lipscore raw score is on a 10-point scale → divide by 2
+ $rawRating = $r['lipscore'] ?? null;
+ $ratingValue = $rawRating !== null ? $rawRating / 2 : ($r['rating'] ?? null);
+
+ if ($ratingValue > 0) {
+ $entry['reviewRating'] = [
+ '@type' => 'Rating',
+ 'ratingValue' => $ratingValue,
+ 'bestRating' => self::RATING_BEST,
+ 'worstRating' => self::RATING_WORST,
+ ];
+ }
+
+ return $entry;
+ }, $reviews);
+ }
+
+ private function filterGtins(array $gtins): array
+ {
+ return array_values(array_filter(
+ $gtins,
+ fn($g) => in_array(strlen((string) $g), self::VALID_GTIN_LENGTHS, true)
+ ));
+ }
+
+ private function gtinName(string $gtin): string
+ {
+ return 'gtin' . strlen($gtin);
+ }
+
+ private function addGtins(array &$schema, array $validGtins): void
+ {
+ foreach ($validGtins as $gtin) {
+ $key = $this->gtinName((string) $gtin);
+ $schema[$key] = $schema[$key] ?? [];
+ $schema[$key][] = $gtin;
+ }
+ }
+
+ private function addProductId(array &$schema, string $sku, string $mpn, array $validGtins): void
+ {
+ $productId = [];
+
+ if ($sku !== '') {
+ $productId[] = "sku:{$sku}";
+ }
+ if ($mpn !== '') {
+ $productId[] = "mpn:{$mpn}";
+ }
+ foreach ($validGtins as $gtin) {
+ $productId[] = $this->gtinName((string) $gtin) . ":{$gtin}";
+ }
+
+ if (!empty($productId)) {
+ $schema['productID'] = $productId;
+ }
+ }
+
+ private function firstValue(string $value): string
+ {
+ return explode(';', $value)[0];
+ }
+
+ private function normalizePrice(mixed $price): string
+ {
+ return number_format((float) str_replace(',', '.', (string) $price), 2, '.', '');
+ }
+
+ private function truncateDescription(string $description): string
+ {
+ return mb_substr(trim(strip_tags($description)), 0, self::MAX_DESC_LENGTH);
+ }
+
+ private function normalizeReviews(array $ratings): array
+ {
+ $raw = $ratings['last_reviews'] ?? [];
+
+ return is_array($raw) ? $raw : (json_decode($raw, true) ?: []);
+ }
+
+ private function getProductData(ProductInterface $product): array
+ {
+ try {
+ return $this->helper->getProductData($product);
+ } catch (\Throwable $e) {
+ $this->logger->warning('Lipscore could not load product data for schema', [
+ 'product_id' => $product->getId(),
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [];
+ }
+ }
+}
diff --git a/Model/Service/JsonLd/LipscoreReadService.php b/Model/Service/JsonLd/LipscoreReadService.php
new file mode 100644
index 0000000..eb86917
--- /dev/null
+++ b/Model/Service/JsonLd/LipscoreReadService.php
@@ -0,0 +1,196 @@
+cache = $cache;
+ $this->repository = $repository;
+ $this->queueRepository = $queueRepository;
+ $this->apiKeyRepository = $apiKeyRepository;
+ $this->request = $request;
+ $this->config = $config;
+ $this->jsonLdBuilder = $jsonLdBuilder;
+ $this->fpcRefresher = $fpcRefresher;
+ $this->logger = $logger;
+ }
+
+ public function getJsonLdProduct(ProductInterface $product): ?array
+ {
+ $ratings = $this->getRatings($product);
+
+ return $ratings
+ ? $this->jsonLdBuilder->build($product, $ratings)
+ : null;
+ }
+
+ public function getJsonLdProductReviews(ProductInterface $product): ?array
+ {
+ $ratings = $this->getRatings($product);
+
+ return $ratings
+ ? $this->jsonLdBuilder->buildReviews($product, $ratings)
+ : null;
+ }
+
+ private function getRatings(ProductInterface $product): ?array
+ {
+ $productId = (int)$product->getId();
+ $keyId = $this->apiKeyRepository->getIdByKey($this->config->getApiKey());
+ if (!$keyId) {
+ return null;
+ }
+
+ $cacheKey = $this->getCacheKey($productId, $keyId);
+
+ // L1 Cache
+ $cached = $this->cache->load($cacheKey);
+ if ($cached !== false) {
+ $decoded = json_decode($cached, true);
+ return is_array($decoded) ? $decoded : null;
+ }
+
+ // L2 DB
+ $row = $this->repository->getProductData($productId, $keyId);
+
+ if (!$row) {
+ $this->enqueue($productId, $keyId, QueueRepository::PRIORITY_HIGH);
+ return null;
+ }
+
+ $age = time() - strtotime((string)$row['updated_at']);
+ $stale = $age > $this->config->getFreshThreshold();
+
+ $ratings = $this->extractRatings($row, $stale);
+
+ $this->saveToCache($cacheKey, $ratings);
+
+ if ($age > $this->config->getStaleThreshold()) {
+ $this->enqueue($productId, $keyId, QueueRepository::PRIORITY_HIGH);
+ } elseif ($stale) {
+ $this->enqueue($productId, $keyId, QueueRepository::PRIORITY_LOW);
+ }
+
+ return $ratings;
+ }
+
+ public function fetchAndSaveBatch(array $productIds, array $context): array
+ {
+ if (!isset($context['key_id'])) {
+ return ['saved' => []];
+ }
+
+ $apiResults = $this->request->fetchProductsBatch($productIds, $context);
+ $saved = [];
+
+ foreach ($apiResults as $data) {
+ $productId = (int)($data['magento_product_id'] ?? 0);
+ if (!$productId) {
+ continue;
+ }
+ try {
+ $this->repository->upsert($productId, $context['key_id'], $data);
+ $this->invalidateCache($productId, $context);
+ $saved[] = $productId;
+ } catch (\Throwable $e) {
+ $this->logger->error('Lipscore save failed', [
+ 'product_id' => $productId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ return ['saved' => $saved];
+ }
+
+ private function extractRatings(array $row, bool $stale): array
+ {
+ $reviews = json_decode((string)($row['last_reviews'] ?? '[]'), true);
+
+ return [
+ 'rating_value' => $row['rating_value'] ?? null,
+ 'review_count' => (int)($row['review_count'] ?? 0),
+ 'votes_count' => (int)($row['rating_count'] ?? 0),
+ 'last_reviews' => is_array($reviews) ? $reviews : [],
+ 'stale' => $stale,
+ ];
+ }
+
+ private function enqueue(int $productId, int $keyId, int $priority): void
+ {
+ try {
+ $this->queueRepository->enqueue($productId, $keyId, $priority);
+ } catch (\Throwable $e) {
+ $this->logger->error('Lipscore enqueue failed', [
+ 'product_id' => $productId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ private function saveToCache(string $key, array $data): void
+ {
+ $this->cache->save(
+ json_encode($data, JSON_THROW_ON_ERROR),
+ $key,
+ [self::CACHE_TAG],
+ $this->config->getCacheTtl()
+ );
+ }
+
+ private function invalidateCache(int $productId, $context): void
+ {
+ $this->cache->remove($this->getCacheKey($productId, $context['key_id']));
+ if ($this->config->isFpcClearEnabled()) {
+ $this->fpcRefresher->refreshProduct($productId);
+ }
+ }
+
+ private function getCacheKey(int $productId, int $keyId): string
+ {
+ return self::CACHE_KEY_RATINGS . $keyId . '_' . $productId;
+ }
+}
diff --git a/Model/Service/JsonLd/ProductResolver.php b/Model/Service/JsonLd/ProductResolver.php
new file mode 100644
index 0000000..1ffc53a
--- /dev/null
+++ b/Model/Service/JsonLd/ProductResolver.php
@@ -0,0 +1,155 @@
+config = $config;
+ $this->resource = $resource;
+ $this->connection = $resource->getConnection();
+ }
+
+ public function resolveProductId(string $internalId): ?int
+ {
+ return match ($this->getAttributeCode()) {
+ 'id' => is_numeric($internalId) ? (int) $internalId : null,
+ 'sku' => $this->resolveBySku($internalId),
+ default => $this->resolveByEavAttribute($this->getAttributeCode(), $internalId),
+ };
+ }
+
+ public function getMagentoToLipscoreMap(array $magentoIds): array
+ {
+ if (empty($magentoIds)) {
+ return [];
+ }
+
+ return match ($this->getAttributeCode()) {
+ 'id' => array_combine(
+ array_map('strval', $magentoIds),
+ array_map('strval', $magentoIds)
+ ),
+ 'sku' => $this->mapBySku($magentoIds),
+ default => $this->mapByEavAttribute($this->getAttributeCode(), $magentoIds),
+ };
+ }
+
+ private function resolveBySku(string $sku): ?int
+ {
+ $select = $this->connection->select()
+ ->from($this->table('catalog_product_entity'), ['entity_id'])
+ ->where('sku = ?', $sku)
+ ->limit(1);
+
+ $id = $this->connection->fetchOne($select);
+ return $id ? (int) $id : null;
+ }
+
+ private function mapBySku(array $magentoIds): array
+ {
+ $select = $this->connection->select()
+ ->from($this->table('catalog_product_entity'), ['entity_id', 'sku'])
+ ->where('entity_id IN (?)', $magentoIds);
+
+ $map = [];
+ foreach ($this->connection->fetchAll($select) as $row) {
+ if (!empty($row['sku'])) {
+ $map[(string) $row['entity_id']] = $row['sku'];
+ }
+ }
+
+ return $map;
+ }
+
+ private function resolveByEavAttribute(string $attributeCode, string $value): ?int
+ {
+ ['id' => $attributeId, 'table' => $table] = $this->getAttributeMeta($attributeCode);
+
+ $select = $this->connection->select()
+ ->from($table, ['entity_id'])
+ ->where('attribute_id = ?', $attributeId)
+ ->where('value = ?', $value)
+ ->limit(1);
+
+ $id = $this->connection->fetchOne($select);
+ return $id ? (int) $id : null;
+ }
+
+ private function mapByEavAttribute(string $attributeCode, array $magentoIds): array
+ {
+ ['id' => $attributeId, 'table' => $table] = $this->getAttributeMeta($attributeCode);
+
+ $select = $this->connection->select()
+ ->from($table, ['entity_id', 'value'])
+ ->where('attribute_id = ?', $attributeId)
+ ->where('entity_id IN (?)', $magentoIds);
+
+ $map = [];
+ foreach ($this->connection->fetchAll($select) as $row) {
+ $value = trim((string) $row['value']);
+ if ($value !== '') {
+ $map[(string) $row['entity_id']] = $value;
+ }
+ }
+
+ return $map;
+ }
+
+ private function getAttributeCode(): string
+ {
+ return $this->config->getProductAttributeId() ?: 'id';
+ }
+
+ private function getAttributeMeta(string $attributeCode): array
+ {
+ if (!isset(self::$attributeCache[$attributeCode])) {
+ $select = $this->connection->select()
+ ->from($this->table('eav_attribute'), ['attribute_id', 'backend_type'])
+ ->where('entity_type_id = ?', 4)
+ ->where('attribute_code = ?', $attributeCode)
+ ->limit(1);
+
+ $row = $this->connection->fetchRow($select);
+
+ if (!$row) {
+ throw new \RuntimeException(
+ "Lipscore: attribute '{$attributeCode}' not found."
+ );
+ }
+
+ self::$attributeCache[$attributeCode] = [
+ 'id' => (int) $row['attribute_id'],
+ 'table' => $this->table(match ($row['backend_type']) {
+ 'text' => 'catalog_product_entity_text',
+ 'int' => 'catalog_product_entity_int',
+ 'decimal' => 'catalog_product_entity_decimal',
+ default => 'catalog_product_entity_varchar',
+ }),
+ ];
+ }
+
+ return self::$attributeCache[$attributeCode];
+ }
+
+ private function table(string $name): string
+ {
+ return $this->resource->getTableName($name);
+ }
+}
diff --git a/Model/System/Config/Backend/WebhookEnabled.php b/Model/System/Config/Backend/WebhookEnabled.php
new file mode 100644
index 0000000..3a2b045
--- /dev/null
+++ b/Model/System/Config/Backend/WebhookEnabled.php
@@ -0,0 +1,64 @@
+logger = $logger;
+ $this->request = $request;
+ $this->apiKeyRepository = $apiKeyRepository;
+
+ parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data);
+ }
+
+ public function afterSave(): static
+ {
+ $newValue = (bool) $this->getValue();
+ $oldValue = (bool) $this->getOldValue();
+
+ if ($newValue === $oldValue) {
+ return parent::afterSave();
+ }
+
+ try {
+ $this->apiKeyRepository->syncKeys();
+ } catch (\Throwable $e) {
+ $this->logger->error('Lipscore webhook config sync failed', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
+ return parent::afterSave();
+ }
+}
diff --git a/Model/WebhookManagement.php b/Model/WebhookManagement.php
new file mode 100644
index 0000000..d0f228b
--- /dev/null
+++ b/Model/WebhookManagement.php
@@ -0,0 +1,114 @@
+queueRepository = $queueRepository;
+ $this->apiKeyRepository = $apiKeyRepository;
+ $this->config = $config;
+ $this->request = $request;
+ $this->productResolver = $productResolver;
+ $this->logger = $logger;
+ }
+
+ public function handle(): bool
+ {
+ if (!$this->config->isWebhookEnabled()) {
+ $this->logger->info(
+ 'Lipscore webhook disabled; responding 410 Gone to trigger provider-side deregistration'
+ );
+ throw new GoneHttpException('Webhook disabled');
+ }
+
+ $body = $this->request->getContent();
+ $payload = json_decode($body, true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new LocalizedException(__('Invalid JSON payload'));
+ }
+
+ $this->verifySignature($body, $this->request->getHeader('Lipscore-Hmac-Sha256-Base64'));
+
+ $internalId = trim((string)($payload['product']['internal_id'] ?? ''));
+ $event = trim((string)($payload['event'] ?? ''));
+
+ if (empty($internalId) || empty($event)) {
+ $this->logger->warning('Lipscore webhook missing fields', [
+ 'keys' => array_keys($data ?? []),
+ ]);
+ throw new LocalizedException(__('Missing product.internal_id or event'));
+ }
+
+ $productId = $this->productResolver->resolveProductId($internalId);
+
+ if (!$productId) {
+ $this->logger->warning('Lipscore webhook product not found', [
+ 'internal_id' => $internalId,
+ ]);
+ return true;
+ }
+
+ $priority = in_array($event, ['rating_created', 'review_created'])
+ ? QueueRepository::PRIORITY_HIGH
+ : QueueRepository::PRIORITY_LOW;
+ $apiKeyId = $this->apiKeyRepository->getIdByKey($this->config->getApiKey());
+ $this->queueRepository->enqueue($productId, $apiKeyId, $priority);
+
+ $this->logger->info('Lipscore webhook enqueued', [
+ 'internal_id' => $internalId,
+ 'product_id' => $productId,
+ 'event' => $event,
+ 'priority' => $priority,
+ ]);
+
+ return true;
+ }
+ private function verifySignature(string $payload, $signature): void
+ {
+ $secret = $this->config->getWebhookHmacSecret();
+ if (!$secret || !$signature) {
+ return;
+ }
+
+ if (ctype_xdigit($secret) && strlen($secret) === 128) {
+ $secret = hex2bin($secret);
+ }
+
+ $expected = base64_encode(
+ hash_hmac('sha256', $payload, $secret, true)
+ );
+
+ if (!hash_equals($expected, $signature)) {
+ $this->logger->warning('Lipscore webhook signature mismatch');
+ throw new LocalizedException(__('Invalid signature'));
+ }
+ }
+
+}
diff --git a/Setup/Patch/Data/SyncInitialApiKeys.php b/Setup/Patch/Data/SyncInitialApiKeys.php
new file mode 100644
index 0000000..beef971
--- /dev/null
+++ b/Setup/Patch/Data/SyncInitialApiKeys.php
@@ -0,0 +1,36 @@
+apiKeyRepository = $apiKeyRepository;
+ }
+
+ public function apply(): self
+ {
+ $this->apiKeyRepository->syncKeys();
+
+ return $this;
+ }
+
+ public static function getDependencies(): array
+ {
+ return [];
+ }
+
+ public function getAliases(): array
+ {
+ return [];
+ }
+}
diff --git a/composer.json b/composer.json
index 4f43e4b..c669d8b 100755
--- a/composer.json
+++ b/composer.json
@@ -7,7 +7,7 @@
"ratings",
"reminders"
],
- "version": "2.1.2",
+ "version": "2.2.0",
"require": {
"php": "~5.5.0|~5.6.0|~7.0|~8.1"
},
diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml
index e4314bb..35d4aa3 100755
--- a/etc/adminhtml/system.xml
+++ b/etc/adminhtml/system.xml
@@ -138,6 +138,56 @@
Lipscore\RatingsReviews\Block\System\Config\Form\Fieldset\Dashboard\Link
+
+
+
+
+
+ Magento\Config\Model\Config\Source\Yesno
+ Lipscore\RatingsReviews\Model\System\Config\Backend\WebhookEnabled
+
+
+
+ Secret key used to verify webhook signatures.
+ Magento\Config\Model\Config\Backend\Encrypted
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Magento\Config\Model\Config\Source\Yesno
+ If Yes, FPC for product pages will be cleared when review/rating is updated.
+
+
+
+
+
+
+
+ Renders rating and review structured data in the initial HTML response for AI and search engine crawlers.
+ Magento\Config\Model\Config\Source\Yesno
+
+
+
+
+
+
+
+ Magento\Config\Model\Config\Source\Yesno
+
+
diff --git a/etc/config.xml b/etc/config.xml
index c860472..fc98cd0 100755
--- a/etc/config.xml
+++ b/etc/config.xml
@@ -25,6 +25,22 @@
0
0
+
+ 24
+ 72
+ 24
+ 0
+
+
+ 0
+
+
+
+ 0
+
+
+ 0
+
diff --git a/etc/crontab.xml b/etc/crontab.xml
new file mode 100644
index 0000000..602cb29
--- /dev/null
+++ b/etc/crontab.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ * * * * *
+
+
+ 0 3 * * *
+
+
+
diff --git a/etc/db_schema.xml b/etc/db_schema.xml
new file mode 100644
index 0000000..49a10ae
--- /dev/null
+++ b/etc/db_schema.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/etc/di.xml b/etc/di.xml
index 708942c..7891c46 100755
--- a/etc/di.xml
+++ b/etc/di.xml
@@ -15,6 +15,7 @@
- \Lipscore\RatingsReviews\Model\Logger\Handler
+ \Lipscore\RatingsReviews\Model\Config
@@ -25,4 +26,37 @@
+
+
+
+
+
+
+ - Lipscore\RatingsReviews\Console\Command\ProcessQueue
+ - Lipscore\RatingsReviews\Console\Command\SyncHooks
+ - Lipscore\RatingsReviews\Console\Command\DeleteAllHooks
+
+
+
+
+
+
+
+ - 15
+ - false
+
+
+
+
+
+
+ LipscoreGuzzleClient
+
+
+
+
+
+ LipscoreGuzzleClient
+
+
diff --git a/etc/webapi.xml b/etc/webapi.xml
new file mode 100644
index 0000000..89a7e40
--- /dev/null
+++ b/etc/webapi.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
diff --git a/view/frontend/layout/catalog_product_view.xml b/view/frontend/layout/catalog_product_view.xml
index 2ee64d6..d5b4aef 100644
--- a/view/frontend/layout/catalog_product_view.xml
+++ b/view/frontend/layout/catalog_product_view.xml
@@ -2,6 +2,13 @@
+
+
+
+
diff --git a/view/frontend/templates/product/jsonld.phtml b/view/frontend/templates/product/jsonld.phtml
new file mode 100644
index 0000000..58a167b
--- /dev/null
+++ b/view/frontend/templates/product/jsonld.phtml
@@ -0,0 +1,19 @@
+getProductJsonLd();
+$productReviewsJsonLd = $block->getReviewsJsonLd();
+?>
+
+
+
+
+
+
+
+