<?php
declare(strict_types=1);
namespace App\Module\Cms\Provider;
use App\Module\Catalog\Provider\CategoryTreeProvider;
use App\Module\Cms\Model\Stories;
use App\Module\Cms\Repository\StoryRepository;
use App\Store\StoreContext;
use Denios\Data\Cms\Story;
use Denios\Data\Product\Product;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Spatie\DataTransferObject\DataTransferObjectError;
use Throwable;
use Symfony\Contracts\Cache\CacheInterface;
/**
* Class CmsContentProvider
*
* Provides content management system utilities for handling and fetching
* Storyblok content including stories, categories, and specific slots. It
* integrates with a caching mechanism to optimize performance and uses
* logging for error and issue tracking. The class supports various
* contextual scenario-specific methods for retrieving content based on keys,
* slugs, IDs, and categories.
*/
class CmsContentProvider implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* @var self::SLOT_PDP_SIDEBAR name of the Storyblock slot
*/
public const SLOT_PDP_SIDEBAR = 'slot_pdp_sidebar';
/**
* @var self::SLOT_PDP_CENTER name of the Storyblock slot
*/
public const SLOT_PDP_CENTER = 'slot_pdp_center';
/**
* @var self::SLOT_PDP_SIDEBAR_DEFAULT name of the default Storyblock slot
*/
public const SLOT_PDP_SIDEBAR_DEFAULT = 'slot_pdp_sidebar_default';
/**
* @var self::SLOT_PDP_SIDEBAR_DEFAULT name of the default Storyblock slot
*/
public const SLOT_PDP_BUTTOM = 'slot_pdp_bottom';
/**
* @var self::REDIS_PREFIX_COMMERCE prefix of the storyblok shop entries in redis
*/
public const REDIS_PREFIX_COMMERCE = 'commerce';
/**
* @var self::STORYBLOK_EXCLUDING_FIELDS field to exclude in Storyblok request,
* commemorated list of fields
* only impact of fields in the content object of the story
*/
public const STORYBLOK_EXCLUDING_FIELDS = 'body';
public const CACHE_PREFIX = "cms";
public const TTL = 600;
private $tempCache = [];
private CacheInterface $cache;
protected ?Story $currentStory;
protected StoreContext $storeContext;
private CategoryTreeProvider $categoryTreeProvider;
private StoryRepository $storyRepository;
/**
* @param StoreContext $storeContext
* @param CategoryTreeProvider $categoryTreeProvider
* @param StoryRepository $storyRepository
* @param CacheInterface $cache
*/
public function __construct(
StoreContext $storeContext,
CategoryTreeProvider $categoryTreeProvider,
StoryRepository $storyRepository,
CacheInterface $cache
) {
$this->storeContext = $storeContext;
$this->categoryTreeProvider = $categoryTreeProvider;
$this->setLogger(new NullLogger());
$this->storyRepository = $storyRepository;
$this->cache = $cache;
$this->currentStory = null;
}
/**
* Creates story
*
* @param array $data
*
* @return Story|null
*/
private function createStory(array $data): ?Story
{
try {
// temporary safetynet during system upgrade (7.04.21, Tim Kirbach)
try {
return new Story($data);
} catch (DataTransferObjectError $e) {
unset($data['structuredData']);
return new Story($data);
}
} catch (Throwable $throwable) {
$storyEssentials = [];
foreach (['id', 'parentId', 'groupId', 'uuid', 'slug', 'fullSlug', 'lang', 'path'] as $key) {
$storyEssentials[$key] = $data[$key] ?? null;
}
$this->logger->critical(
'Malformed story detected: ' . $throwable->getMessage(),
[
'storyEssentials' => $storyEssentials,
'throwableClass' => get_class($throwable),
'traceAsString' => $throwable->getTraceAsString(),
]
);
return null;
}
}
/**
* Returns account slots for the storefront
*
* @return array|null
*/
public function getAccountSlots(): ?array
{
$key = sprintf(
'account:%s:%s:account-elements',
$this->storeContext->getAlias(),
strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale())
);
$content = null;
$storyData = $this->storyRepository->getStoryByRedisKeyAsArray($key);
if (is_array($storyData)) {
$content = $this->createStory($storyData);
}
return $content ? $content->content : [];
}
public function getTrackingSlots(): ?array
{
$key = sprintf(
'tracking:%s:%s',
$this->storeContext->getAlias(),
strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale())
);
$content = null;
$storyData = $this->storyRepository->getStoryByRedisKeyAsArray($key);
if (is_array($storyData)) {
$content = $this->createStory($storyData);
}
return $content ? $content->content : [];
}
/**
* Load Storyblock Category by slug
*
* @param string $slug
*
* @return array|null
*/
public function getCategoryContentBySlug(string $slug): ?array
{
$localeInfo = $this->storeContext->getLocaleInfo();
$redisKey = $this->getRedisKey(
$this->storeContext->getAlias(),
$localeInfo->getCurrentLocale(),
$slug,
self::REDIS_PREFIX_COMMERCE
);
return $this->storyRepository->getStoryByRedisKeyAsArray($redisKey);
}
/**
* Returns the redis keys to load slot content from the current category path
*
* @param string $categoryId
*
* @return array
*/
private function getCategoryPathCmsContentKeys(string $categoryId): array
{
$categories = $this->categoryTreeProvider->getFlat();
$categoryKeys = [];
$locale = strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale());
$store = $this->storeContext->getAlias();
while (isset($categories[$categoryId])) {
$category = $categories[$categoryId];
if (count($this->storeContext->getLanguages())> 1 && $locale !== $this->storeContext->getDefaultLocale()) {
$slugPrefix = $this->storeContext->getLocaleInfo()->getPrefix();
$cleanSlug = trim(ltrim($category['slug'], '/'.$slugPrefix.'/'), '/');
$categoryKeys[] = sprintf('commerce:%s:%s:%s', $store, $locale, $cleanSlug);
$categoryKeys[] = sprintf('%s:%s:%s', $store, $locale, $cleanSlug);
} else {
$categoryKeys[] = sprintf('commerce:%s:%s:%s', $store, $locale, trim($category['slug'], '/'));
$categoryKeys[] = sprintf('%s:%s:%s', $store, $locale, trim($category['slug'], '/'));
}
$categoryId = $category['parent'];
}
return $categoryKeys;
}
/**
* Gets content by id
*
* @param string $contentId
*
* @return Story|null
*/
public function getContentById(string $contentId): ?Story
{
$content = null;
$storyData = $this->storyRepository->getStoryByIdAsArray($contentId);
if (isset($this->tempCache[$contentId])) {
return $this->tempCache[$contentId];
}
if (is_array($storyData)) {
$content = $this->createStory($storyData);
$this->tempCache[$contentId] = $content;
}
return $content;
}
/**
* @param string $store
* @param string $locale
* @param string $slug
*
* @param string|null $prefix
*
* @return Story|null
*/
public function getContentBySlug(string $store, string $locale, string $slug, string $prefix = null): ?Story
{
$redisKey = $this->getRedisKey($store, $locale, $slug, $prefix);
$content = null;
$storyData = $this->storyRepository->getStoryByRedisKeyAsArray($redisKey);
if (is_array($storyData)) {
$content = $this->createStory($storyData);
}
return $content;
}
/**
* @param string $store
* @param string $locale
* @param string $slug
*
* @param string|null $prefix
*
* @return Story|null
*/
public function getContentBySlugWithCache(string $store, string $locale, string $slug, string $prefix = null): ?Story
{
$redisKey = $this->getRedisKey($store, $locale, $slug, $prefix);
$cacheItem = $this->cache->getItem($this::CACHE_PREFIX . $redisKey);
if (!$cacheItem->isHit()) {
$content = $this->getContentBySlug($store, $locale, $slug, $prefix = null);
if (isset($content) && !empty($content)) {
$cacheItem->set($content);
$cacheItem->expiresAfter($this::TTL);
$this->cache->save($cacheItem);
}
} else {
$content = $cacheItem->get();
}
return $content;
}
/**
* Returns content for current store and locale
*
* @param string|null $prefix
*
* @return Story|null
*/
public function getContentBySlugForCurrentPage(string $prefix = null): ?Story
{
if ($this->currentStory === null) {
$localeInfo = $this->storeContext->getLocaleInfo();
$this->currentStory = $this->getContentBySlug(
$this->storeContext->getAlias(),
$localeInfo->getCurrentLocale(),
$localeInfo->getPath(),
$prefix
);
}
return $this->currentStory;
}
/**
* Builds a unique hash string based on the provided parameters.
*
* The hash is used to identify content uniquely based on topics, types,
* limit, page, and specific story IDs.
*
* @param array $topics An array of topic identifiers.
* @param array $types An array of type identifiers.
* @param int $limit The maximum number of results to limit to.
* @param int $page The page number for pagination.
* @param array $storyIds An array of story IDs to include.
*
* @return string The unique MD5 hash string derived from the given parameters.
*/
private function buildHashFromParams(array $topics, array $types, int $limit = 0, int $page = 1, array $storyIds = [], string $ignoredStories= ''): string
{
sort($topics);
sort($types);
sort($storyIds);
$keyData = implode('|', [
implode(',', $topics),
implode(',', $types),
$limit,
$page,
implode(',', $storyIds),
$ignoredStories,
]);
return md5($keyData);
}
private function buildCacheKey(array $topics, array $types, int $limit = 0, int $page = 1, array $storyIds = [], string $ignoredStories = ''): string
{
$cacheKey = $this->storeContext->getLocaleInfo()->getCurrentLocale();
$cacheKey = $cacheKey . ':' . $this->buildHashFromParams($topics, $types, $limit, $page, $storyIds, $ignoredStories);
return $this::CACHE_PREFIX . $cacheKey;
}
/**
* Fetches content stories based on specified topics and types with optional pagination and additional filters.
*
* The method manages caching for efficient retrieval of repeated queries. If there is a cached result
* matching the parameters, it will be returned. Otherwise, it queries the repository, builds the result,
* caches it, and then returns the data.
*
* @param array $topics An array of topic identifiers to filter stories by
* @param array $types An array of type identifiers to filter stories by
* @param int $limit The maximum number of results per page (default: 0, which means no limit)
* @param int $page The page number for pagination (default: 1)
* @param array $storyIds An array of specific story IDs to include in the results
*
* @return Stories The collection of stories matching the provided filters
*/
public function getContentByTopicsAndTypes(array $topics, array $types, int $limit = 0, int $page = 1, array $storyIds = [], string $ignoredStories = ''): Stories
{
$ignoredStoriesArray = $ignoredStories === '' ? [] : explode(',', $ignoredStories);
$cacheKey = $this->buildCacheKey($topics, $types, $limit, $page, $storyIds, $ignoredStories);
$cachedStories = $this->cache->getItem($cacheKey);
if ($cachedStories->isHit()) {
return $cachedStories->get();
}
if (!empty($storyIds) && $page === 1 && empty($topics) && empty($types)) {
$filteredStoriesIds = array_diff($storyIds, $ignoredStoriesArray);
$storyResult = $this->storyRepository->getStoriesByIdsAsArray($filteredStoriesIds);
$total = count($storyResult);
if($total > $limit) {
$storyResult = array_slice($storyResult, 0, $limit);
}
$stories = new Stories($storyResult, $limit);
} else {
$upperLimit = $limit;
if (!empty($ignoredStoriesArray)) {
$upperLimit = $limit + count($ignoredStoriesArray);
}
[$storyIds, $total] = $this->storyRepository->getStoryIdsByTopicsAndTypes(
$topics,
$types,
$this->storeContext,
$upperLimit,
$page,
$storyIds,
self::STORYBLOK_EXCLUDING_FIELDS
);
$filteredStoriesIds = array_diff($storyIds, $ignoredStoriesArray);
$storyArrays = $this->storyRepository->getStoriesByIdsAsArray($filteredStoriesIds);
if($total > $limit) {
$storyArrays = array_slice($storyArrays, 0, $limit);
}
$stories = new Stories($storyArrays, $total);
}
if ($total > 0) {
$cachedStories->set($stories);
$cachedStories->expiresAfter($this::TTL*2);
$this->cache->save($cachedStories);
}
return $stories;
}
/**
* Get The PDP Slots of the Parent Categories
*
* @param Product $product
*
* @return array|null
*/
public function getDetailContent(Product $product): ?array
{
$keys = $this->getCategoryPathCmsContentKeys(current($product->categories));
$storyData = $this->storyRepository->getStoriesByRedisKeysAsArray($keys);
$slots = null;
$slots[self::SLOT_PDP_SIDEBAR] = $this->getFirstContentSlotBySlotName(
$storyData,
self::SLOT_PDP_SIDEBAR
);
$slots[self::SLOT_PDP_SIDEBAR_DEFAULT] = $this->getFirstContentSlotBySlotName(
$storyData,
self::SLOT_PDP_SIDEBAR_DEFAULT
);
$slots[self::SLOT_PDP_BUTTOM] = $this->getFirstContentSlotBySlotName(
$storyData,
self::SLOT_PDP_BUTTOM
);
$slots[self::SLOT_PDP_CENTER ] = $this->getFirstContentSlotBySlotName(
$storyData,
self::SLOT_PDP_CENTER
);
return $slots;
}
/**
* Return the First filled Slot by with the given Name
*
* @param array $categorySlotContents All available slots
* @param string $slotName Name of the Slot
*
* @return mixed | null
*/
private function getFirstContentSlotBySlotName(array $categorySlotContents, string $slotName)
{
$slot = null;
foreach ($categorySlotContents as $categorySlotContent) {
if ($categorySlotContent !== null
&& isset($categorySlotContent['content'][$slotName])
&& count($categorySlotContent['content'][$slotName]) > 0
) {
$slot = $categorySlotContent['content'][$slotName];
break;
}
}
return $slot;
}
/**
* Returns global slots for the storefront
*
* @return array
*/
public function getGlobalSlots(): array
{
$key = sprintf(
'global:%s:%s:global-elements',
$this->storeContext->getAlias(),
strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale())
);
$content = null;
$storyData = $this->storyRepository->getStoryByRedisKeyAsArray($key);
if (is_array($storyData)) {
$content = $this->createStory($storyData);
}
return $content ? $content->content : [];
}
public function getActionReferences(): array
{
$key = sprintf(
'global:%s:%s:global-action-references',
$this->storeContext->getAlias(),
strtolower($this->storeContext->getLocaleInfo()->getCurrentLocale())
);
$content = null;
$storyData = $this->storyRepository->getStoryByRedisKeyAsArray($key);
if (is_array($storyData)) {
$content = $this->createStory($storyData);
}
return $content ? $content->content : [];
}
/**
* @param string $store
* @param string $locale
* @param string $slug
* @param string|null $prefix
*
* @return string
*/
public function getRedisKey(string $store, string $locale, string $slug, string $prefix = null): string
{
$key = sprintf('%s:%s:%s', $store, strtolower($locale), trim($slug, '\//'));
if ($prefix !== null) {
$key = sprintf('%s:%s', $prefix, $key);
}
return $key;
}
}