src/Module/Cms/Provider/CmsContentProvider.php line 109

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Module\Cms\Provider;
  4. use App\Module\Catalog\Provider\CategoryTreeProvider;
  5. use App\Module\Cms\Model\Stories;
  6. use App\Module\Cms\Repository\StoryRepository;
  7. use App\Store\StoreContext;
  8. use Denios\Data\Cms\Story;
  9. use Denios\Data\Product\Product;
  10. use Psr\Log\LoggerAwareInterface;
  11. use Psr\Log\LoggerAwareTrait;
  12. use Psr\Log\NullLogger;
  13. use Spatie\DataTransferObject\DataTransferObjectError;
  14. use Throwable;
  15. use Symfony\Contracts\Cache\CacheInterface;
  16. /**
  17. * Class CmsContentProvider
  18. *
  19. * Provides content management system utilities for handling and fetching
  20. * Storyblok content including stories, categories, and specific slots. It
  21. * integrates with a caching mechanism to optimize performance and uses
  22. * logging for error and issue tracking. The class supports various
  23. * contextual scenario-specific methods for retrieving content based on keys,
  24. * slugs, IDs, and categories.
  25. */
  26. class CmsContentProvider implements LoggerAwareInterface
  27. {
  28. use LoggerAwareTrait;
  29. /**
  30. * @var self::SLOT_PDP_SIDEBAR name of the Storyblock slot
  31. */
  32. public const SLOT_PDP_SIDEBAR = 'slot_pdp_sidebar';
  33. /**
  34. * @var self::SLOT_PDP_CENTER name of the Storyblock slot
  35. */
  36. public const SLOT_PDP_CENTER = 'slot_pdp_center';
  37. /**
  38. * @var self::SLOT_PDP_SIDEBAR_DEFAULT name of the default Storyblock slot
  39. */
  40. public const SLOT_PDP_SIDEBAR_DEFAULT = 'slot_pdp_sidebar_default';
  41. /**
  42. * @var self::SLOT_PDP_SIDEBAR_DEFAULT name of the default Storyblock slot
  43. */
  44. public const SLOT_PDP_BUTTOM = 'slot_pdp_bottom';
  45. /**
  46. * @var self::REDIS_PREFIX_COMMERCE prefix of the storyblok shop entries in redis
  47. */
  48. public const REDIS_PREFIX_COMMERCE = 'commerce';
  49. /**
  50. * @var self::STORYBLOK_EXCLUDING_FIELDS field to exclude in Storyblok request,
  51. * commemorated list of fields
  52. * only impact of fields in the content object of the story
  53. */
  54. public const STORYBLOK_EXCLUDING_FIELDS = 'body';
  55. public const CACHE_PREFIX = "cms";
  56. public const TTL = 600;
  57. private $tempCache = [];
  58. private CacheInterface $cache;
  59. protected ?Story $currentStory;
  60. protected StoreContext $storeContext;
  61. private CategoryTreeProvider $categoryTreeProvider;
  62. private StoryRepository $storyRepository;
  63. /**
  64. * @param StoreContext $storeContext
  65. * @param CategoryTreeProvider $categoryTreeProvider
  66. * @param StoryRepository $storyRepository
  67. * @param CacheInterface $cache
  68. */
  69. public function __construct(
  70. StoreContext $storeContext,
  71. CategoryTreeProvider $categoryTreeProvider,
  72. StoryRepository $storyRepository,
  73. CacheInterface $cache
  74. ) {
  75. $this->storeContext = $storeContext;
  76. $this->categoryTreeProvider = $categoryTreeProvider;
  77. $this->setLogger(new NullLogger());
  78. $this->storyRepository = $storyRepository;
  79. $this->cache = $cache;
  80. $this->currentStory = null;
  81. }
  82. /**
  83. * Creates story
  84. *
  85. * @param array $data
  86. *
  87. * @return Story|null
  88. */
  89. private function createStory(array $data): ?Story
  90. {
  91. try {
  92. // temporary safetynet during system upgrade (7.04.21, Tim Kirbach)
  93. try {
  94. return new Story($data);
  95. } catch (DataTransferObjectError $e) {
  96. unset($data['structuredData']);
  97. return new Story($data);
  98. }
  99. } catch (Throwable $throwable) {
  100. $storyEssentials = [];
  101. foreach (['id', 'parentId', 'groupId', 'uuid', 'slug', 'fullSlug', 'lang', 'path'] as $key) {
  102. $storyEssentials[$key] = $data[$key] ?? null;
  103. }
  104. $this->logger->critical(
  105. 'Malformed story detected: ' . $throwable->getMessage(),
  106. [
  107. 'storyEssentials' => $storyEssentials,
  108. 'throwableClass' => get_class($throwable),
  109. 'traceAsString' => $throwable->getTraceAsString(),
  110. ]
  111. );
  112. return null;
  113. }
  114. }
  115. /**
  116. * Returns account slots for the storefront
  117. *
  118. * @return array|null
  119. */
  120. public function getAccountSlots(): ?array
  121. {
  122. $key = sprintf(
  123. 'account:%s:%s:account-elements',
  124. $this->storeContext->getAlias(),
  125. strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale())
  126. );
  127. $content = null;
  128. $storyData = $this->storyRepository->getStoryByRedisKeyAsArray($key);
  129. if (is_array($storyData)) {
  130. $content = $this->createStory($storyData);
  131. }
  132. return $content ? $content->content : [];
  133. }
  134. public function getTrackingSlots(): ?array
  135. {
  136. $key = sprintf(
  137. 'tracking:%s:%s',
  138. $this->storeContext->getAlias(),
  139. strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale())
  140. );
  141. $content = null;
  142. $storyData = $this->storyRepository->getStoryByRedisKeyAsArray($key);
  143. if (is_array($storyData)) {
  144. $content = $this->createStory($storyData);
  145. }
  146. return $content ? $content->content : [];
  147. }
  148. /**
  149. * Load Storyblock Category by slug
  150. *
  151. * @param string $slug
  152. *
  153. * @return array|null
  154. */
  155. public function getCategoryContentBySlug(string $slug): ?array
  156. {
  157. $localeInfo = $this->storeContext->getLocaleInfo();
  158. $redisKey = $this->getRedisKey(
  159. $this->storeContext->getAlias(),
  160. $localeInfo->getCurrentLocale(),
  161. $slug,
  162. self::REDIS_PREFIX_COMMERCE
  163. );
  164. return $this->storyRepository->getStoryByRedisKeyAsArray($redisKey);
  165. }
  166. /**
  167. * Returns the redis keys to load slot content from the current category path
  168. *
  169. * @param string $categoryId
  170. *
  171. * @return array
  172. */
  173. private function getCategoryPathCmsContentKeys(string $categoryId): array
  174. {
  175. $categories = $this->categoryTreeProvider->getFlat();
  176. $categoryKeys = [];
  177. $locale = strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale());
  178. $store = $this->storeContext->getAlias();
  179. while (isset($categories[$categoryId])) {
  180. $category = $categories[$categoryId];
  181. if (count($this->storeContext->getLanguages())> 1 && $locale !== $this->storeContext->getDefaultLocale()) {
  182. $slugPrefix = $this->storeContext->getLocaleInfo()->getPrefix();
  183. $cleanSlug = trim(ltrim($category['slug'], '/'.$slugPrefix.'/'), '/');
  184. $categoryKeys[] = sprintf('commerce:%s:%s:%s', $store, $locale, $cleanSlug);
  185. $categoryKeys[] = sprintf('%s:%s:%s', $store, $locale, $cleanSlug);
  186. } else {
  187. $categoryKeys[] = sprintf('commerce:%s:%s:%s', $store, $locale, trim($category['slug'], '/'));
  188. $categoryKeys[] = sprintf('%s:%s:%s', $store, $locale, trim($category['slug'], '/'));
  189. }
  190. $categoryId = $category['parent'];
  191. }
  192. return $categoryKeys;
  193. }
  194. /**
  195. * Gets content by id
  196. *
  197. * @param string $contentId
  198. *
  199. * @return Story|null
  200. */
  201. public function getContentById(string $contentId): ?Story
  202. {
  203. $content = null;
  204. $storyData = $this->storyRepository->getStoryByIdAsArray($contentId);
  205. if (isset($this->tempCache[$contentId])) {
  206. return $this->tempCache[$contentId];
  207. }
  208. if (is_array($storyData)) {
  209. $content = $this->createStory($storyData);
  210. $this->tempCache[$contentId] = $content;
  211. }
  212. return $content;
  213. }
  214. /**
  215. * @param string $store
  216. * @param string $locale
  217. * @param string $slug
  218. *
  219. * @param string|null $prefix
  220. *
  221. * @return Story|null
  222. */
  223. public function getContentBySlug(string $store, string $locale, string $slug, string $prefix = null): ?Story
  224. {
  225. $redisKey = $this->getRedisKey($store, $locale, $slug, $prefix);
  226. $content = null;
  227. $storyData = $this->storyRepository->getStoryByRedisKeyAsArray($redisKey);
  228. if (is_array($storyData)) {
  229. $content = $this->createStory($storyData);
  230. }
  231. return $content;
  232. }
  233. /**
  234. * @param string $store
  235. * @param string $locale
  236. * @param string $slug
  237. *
  238. * @param string|null $prefix
  239. *
  240. * @return Story|null
  241. */
  242. public function getContentBySlugWithCache(string $store, string $locale, string $slug, string $prefix = null): ?Story
  243. {
  244. $redisKey = $this->getRedisKey($store, $locale, $slug, $prefix);
  245. $cacheItem = $this->cache->getItem($this::CACHE_PREFIX . $redisKey);
  246. if (!$cacheItem->isHit()) {
  247. $content = $this->getContentBySlug($store, $locale, $slug, $prefix = null);
  248. if (isset($content) && !empty($content)) {
  249. $cacheItem->set($content);
  250. $cacheItem->expiresAfter($this::TTL);
  251. $this->cache->save($cacheItem);
  252. }
  253. } else {
  254. $content = $cacheItem->get();
  255. }
  256. return $content;
  257. }
  258. /**
  259. * Returns content for current store and locale
  260. *
  261. * @param string|null $prefix
  262. *
  263. * @return Story|null
  264. */
  265. public function getContentBySlugForCurrentPage(string $prefix = null): ?Story
  266. {
  267. if ($this->currentStory === null) {
  268. $localeInfo = $this->storeContext->getLocaleInfo();
  269. $this->currentStory = $this->getContentBySlug(
  270. $this->storeContext->getAlias(),
  271. $localeInfo->getCurrentLocale(),
  272. $localeInfo->getPath(),
  273. $prefix
  274. );
  275. }
  276. return $this->currentStory;
  277. }
  278. /**
  279. * Builds a unique hash string based on the provided parameters.
  280. *
  281. * The hash is used to identify content uniquely based on topics, types,
  282. * limit, page, and specific story IDs.
  283. *
  284. * @param array $topics An array of topic identifiers.
  285. * @param array $types An array of type identifiers.
  286. * @param int $limit The maximum number of results to limit to.
  287. * @param int $page The page number for pagination.
  288. * @param array $storyIds An array of story IDs to include.
  289. *
  290. * @return string The unique MD5 hash string derived from the given parameters.
  291. */
  292. private function buildHashFromParams(array $topics, array $types, int $limit = 0, int $page = 1, array $storyIds = [], string $ignoredStories= ''): string
  293. {
  294. sort($topics);
  295. sort($types);
  296. sort($storyIds);
  297. $keyData = implode('|', [
  298. implode(',', $topics),
  299. implode(',', $types),
  300. $limit,
  301. $page,
  302. implode(',', $storyIds),
  303. $ignoredStories,
  304. ]);
  305. return md5($keyData);
  306. }
  307. private function buildCacheKey(array $topics, array $types, int $limit = 0, int $page = 1, array $storyIds = [], string $ignoredStories = ''): string
  308. {
  309. $cacheKey = $this->storeContext->getLocaleInfo()->getCurrentLocale();
  310. $cacheKey = $cacheKey . ':' . $this->buildHashFromParams($topics, $types, $limit, $page, $storyIds, $ignoredStories);
  311. return $this::CACHE_PREFIX . $cacheKey;
  312. }
  313. /**
  314. * Fetches content stories based on specified topics and types with optional pagination and additional filters.
  315. *
  316. * The method manages caching for efficient retrieval of repeated queries. If there is a cached result
  317. * matching the parameters, it will be returned. Otherwise, it queries the repository, builds the result,
  318. * caches it, and then returns the data.
  319. *
  320. * @param array $topics An array of topic identifiers to filter stories by
  321. * @param array $types An array of type identifiers to filter stories by
  322. * @param int $limit The maximum number of results per page (default: 0, which means no limit)
  323. * @param int $page The page number for pagination (default: 1)
  324. * @param array $storyIds An array of specific story IDs to include in the results
  325. *
  326. * @return Stories The collection of stories matching the provided filters
  327. */
  328. public function getContentByTopicsAndTypes(array $topics, array $types, int $limit = 0, int $page = 1, array $storyIds = [], string $ignoredStories = ''): Stories
  329. {
  330. $ignoredStoriesArray = $ignoredStories === '' ? [] : explode(',', $ignoredStories);
  331. $cacheKey = $this->buildCacheKey($topics, $types, $limit, $page, $storyIds, $ignoredStories);
  332. $cachedStories = $this->cache->getItem($cacheKey);
  333. if ($cachedStories->isHit()) {
  334. return $cachedStories->get();
  335. }
  336. if (!empty($storyIds) && $page === 1 && empty($topics) && empty($types)) {
  337. $filteredStoriesIds = array_diff($storyIds, $ignoredStoriesArray);
  338. $storyResult = $this->storyRepository->getStoriesByIdsAsArray($filteredStoriesIds);
  339. $total = count($storyResult);
  340. if($total > $limit) {
  341. $storyResult = array_slice($storyResult, 0, $limit);
  342. }
  343. $stories = new Stories($storyResult, $limit);
  344. } else {
  345. $upperLimit = $limit;
  346. if (!empty($ignoredStoriesArray)) {
  347. $upperLimit = $limit + count($ignoredStoriesArray);
  348. }
  349. [$storyIds, $total] = $this->storyRepository->getStoryIdsByTopicsAndTypes(
  350. $topics,
  351. $types,
  352. $this->storeContext,
  353. $upperLimit,
  354. $page,
  355. $storyIds,
  356. self::STORYBLOK_EXCLUDING_FIELDS
  357. );
  358. $filteredStoriesIds = array_diff($storyIds, $ignoredStoriesArray);
  359. $storyArrays = $this->storyRepository->getStoriesByIdsAsArray($filteredStoriesIds);
  360. if($total > $limit) {
  361. $storyArrays = array_slice($storyArrays, 0, $limit);
  362. }
  363. $stories = new Stories($storyArrays, $total);
  364. }
  365. if ($total > 0) {
  366. $cachedStories->set($stories);
  367. $cachedStories->expiresAfter($this::TTL*2);
  368. $this->cache->save($cachedStories);
  369. }
  370. return $stories;
  371. }
  372. /**
  373. * Get The PDP Slots of the Parent Categories
  374. *
  375. * @param Product $product
  376. *
  377. * @return array|null
  378. */
  379. public function getDetailContent(Product $product): ?array
  380. {
  381. $keys = $this->getCategoryPathCmsContentKeys(current($product->categories));
  382. $storyData = $this->storyRepository->getStoriesByRedisKeysAsArray($keys);
  383. $slots = null;
  384. $slots[self::SLOT_PDP_SIDEBAR] = $this->getFirstContentSlotBySlotName(
  385. $storyData,
  386. self::SLOT_PDP_SIDEBAR
  387. );
  388. $slots[self::SLOT_PDP_SIDEBAR_DEFAULT] = $this->getFirstContentSlotBySlotName(
  389. $storyData,
  390. self::SLOT_PDP_SIDEBAR_DEFAULT
  391. );
  392. $slots[self::SLOT_PDP_BUTTOM] = $this->getFirstContentSlotBySlotName(
  393. $storyData,
  394. self::SLOT_PDP_BUTTOM
  395. );
  396. $slots[self::SLOT_PDP_CENTER ] = $this->getFirstContentSlotBySlotName(
  397. $storyData,
  398. self::SLOT_PDP_CENTER
  399. );
  400. return $slots;
  401. }
  402. /**
  403. * Return the First filled Slot by with the given Name
  404. *
  405. * @param array $categorySlotContents All available slots
  406. * @param string $slotName Name of the Slot
  407. *
  408. * @return mixed | null
  409. */
  410. private function getFirstContentSlotBySlotName(array $categorySlotContents, string $slotName)
  411. {
  412. $slot = null;
  413. foreach ($categorySlotContents as $categorySlotContent) {
  414. if ($categorySlotContent !== null
  415. && isset($categorySlotContent['content'][$slotName])
  416. && count($categorySlotContent['content'][$slotName]) > 0
  417. ) {
  418. $slot = $categorySlotContent['content'][$slotName];
  419. break;
  420. }
  421. }
  422. return $slot;
  423. }
  424. /**
  425. * Returns global slots for the storefront
  426. *
  427. * @return array
  428. */
  429. public function getGlobalSlots(): array
  430. {
  431. $key = sprintf(
  432. 'global:%s:%s:global-elements',
  433. $this->storeContext->getAlias(),
  434. strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale())
  435. );
  436. $content = null;
  437. $storyData = $this->storyRepository->getStoryByRedisKeyAsArray($key);
  438. if (is_array($storyData)) {
  439. $content = $this->createStory($storyData);
  440. }
  441. return $content ? $content->content : [];
  442. }
  443. public function getActionReferences(): array
  444. {
  445. $key = sprintf(
  446. 'global:%s:%s:global-action-references',
  447. $this->storeContext->getAlias(),
  448. strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale())
  449. );
  450. $content = null;
  451. $storyData = $this->storyRepository->getStoryByRedisKeyAsArray($key);
  452. if (is_array($storyData)) {
  453. $content = $this->createStory($storyData);
  454. }
  455. return $content ? $content->content : [];
  456. }
  457. /**
  458. * @param string $store
  459. * @param string $locale
  460. * @param string $slug
  461. * @param string|null $prefix
  462. *
  463. * @return string
  464. */
  465. public function getRedisKey(string $store, string $locale, string $slug, string $prefix = null): string
  466. {
  467. $key = sprintf('%s:%s:%s', $store, strtolower($locale), trim($slug, '\//'));
  468. if ($prefix !== null) {
  469. $key = sprintf('%s:%s', $prefix, $key);
  470. }
  471. return $key;
  472. }
  473. }