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(); +?> + + + + + + + +