Restore point before Stufe 3 (central resolver-service refactoring). Includes this session's work: - Success Story (usecase) rebuild: new fields/tabs, UsecaseSerializer, thin List/Show renderers, MM relations, inline content elements. - vitec_columns content block (two-column layout with per-item content) incl. unified header section; special-cased JSON resolution. - Container per-column flex (items[].config align/justify) + gap on parent. - Unified header section across CEs/plugins/containers; header fields in JSON. - ISO-style architecture spec: Documentation/Headless-JSON-Architecture.md. - Cleanup: removed local scratch + verified .bak backups. - Fixes: B-6 (undefined thumbnail variable in Downloadcardcollection image resolver), B-7 (unsatisfiable legacy CType OR branch in Downloadcard/Datasheets). Rollback: git reset --hard snapshot-2026-07-09-pre-stufe3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
531 lines
20 KiB
PHP
531 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Service;
|
|
|
|
use Doctrine\DBAL\ParameterType;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Extbase\Service\ImageService;
|
|
|
|
/**
|
|
* Serialises a Success Story (tx_vitec_domain_model_usecase) DB row to the
|
|
* headless JSON shapes consumed by the React frontend.
|
|
*
|
|
* serializeListItem() -> lean card data for the list plugin
|
|
* serializeDetail() -> full detail (hero, content elements, related, seo)
|
|
*
|
|
* Inline content elements are resolved flat; the VITEC Columns content block
|
|
* (vitec_columns) is resolved specially into { header…, layout, columns:{…} }.
|
|
*
|
|
* All relation/FAL resolution lives here so the List/Show UserFunc renderers
|
|
* stay thin. Every resolver is exception-safe and returns null/[] on failure.
|
|
*/
|
|
final class UsecaseSerializer
|
|
{
|
|
private const TABLE = 'tx_vitec_domain_model_usecase';
|
|
private const IMAGE_WIDTHS = [400, 800, 1200, 1600];
|
|
private const COLUMNS_CTYPE = 'vitec_columns';
|
|
|
|
/**
|
|
* @param array<string,mixed> $u
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function serializeListItem(array $u): array
|
|
{
|
|
$uid = (int)$u['uid'];
|
|
|
|
return [
|
|
'uid' => $uid,
|
|
'title' => (string)($u['title'] ?? ''),
|
|
'slug' => (string)($u['slug'] ?? ''),
|
|
'subtitle' => (string)($u['subtitle'] ?? ''),
|
|
'teaser' => (string)($u['teaser'] ?? ''),
|
|
'cardImage' => $this->image($uid, 'card_image'),
|
|
'customerLogo' => $this->image($uid, 'customer_logo'),
|
|
'market' => $this->market((int)($u['market'] ?? 0)),
|
|
'categories' => $this->categories($uid),
|
|
'featured' => (bool)($u['featured'] ?? false),
|
|
'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'),
|
|
'backgroundVariant' => (string)($u['background_variant'] ?? 'none'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $u
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function serializeDetail(array $u): array
|
|
{
|
|
$uid = (int)$u['uid'];
|
|
$base = $this->serializeListItem($u);
|
|
|
|
return array_merge($base, [
|
|
'hero' => [
|
|
'bgImage' => $this->image($uid, 'hero_bgimage'),
|
|
'smallImage' => $this->image($uid, 'hero_small_image'),
|
|
'video' => $this->video($uid, 'hero_video'),
|
|
'overlayColor' => (string)($u['hero_overlay_color'] ?? ''),
|
|
'overlayOpacity' => (float)($u['hero_overlay_opacity'] ?? 0),
|
|
'layout' => (string)($u['hero_layout'] ?? 'fullscreen'),
|
|
'textTheme' => (string)($u['text_theme'] ?? 'light'),
|
|
],
|
|
'contentElements' => $this->contentElements($uid),
|
|
'related' => [
|
|
'show' => (bool)($u['show_related'] ?? true),
|
|
'market' => $base['market'],
|
|
'solutions' => $this->relationMulti('tx_vitec_usecase_solution_mm', 'tx_vitec_domain_model_solution', $uid),
|
|
'products' => $this->relationMulti('tx_vitec_usecase_product_mm', 'tx_vitec_domain_model_product', $uid),
|
|
'categories' => $base['categories'],
|
|
],
|
|
'seo' => $this->seo($u),
|
|
'appearance' => [
|
|
'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'),
|
|
'backgroundVariant' => (string)($u['background_variant'] ?? 'none'),
|
|
'accentColor' => (string)($u['accent_color'] ?? ''),
|
|
'featured' => (bool)($u['featured'] ?? false),
|
|
'heroLayout' => (string)($u['hero_layout'] ?? 'fullscreen'),
|
|
'textTheme' => (string)($u['text_theme'] ?? 'light'),
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- images
|
|
|
|
/**
|
|
* FAL image (with srcset). $table defaults to the usecase table but can be
|
|
* a content-block collection table (for Columns items).
|
|
*
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public function image(int $recordUid, string $fieldName, ?string $table = null): ?array
|
|
{
|
|
try {
|
|
$table = $table ?? self::TABLE;
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_file_reference');
|
|
$ref = $qb
|
|
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
|
|
->from('sys_file_reference', 'sfr')
|
|
->where(
|
|
$qb->expr()->eq('sfr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
|
|
$qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
|
|
$qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('sfr.deleted', 0),
|
|
$qb->expr()->eq('sfr.hidden', 0)
|
|
)
|
|
->orderBy('sfr.sorting_foreign')
|
|
->setMaxResults(1)
|
|
->executeQuery()
|
|
->fetchAssociative();
|
|
|
|
if (!$ref) {
|
|
return null;
|
|
}
|
|
|
|
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
|
|
$imageService = GeneralUtility::makeInstance(ImageService::class);
|
|
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
|
|
|
|
$srcset = [];
|
|
foreach (self::IMAGE_WIDTHS as $width) {
|
|
$variant = $imageService->applyProcessingInstructions(
|
|
$fileReference,
|
|
['width' => $width, 'crop' => $ref['crop'] ?? null]
|
|
);
|
|
$srcset[] = [
|
|
'url' => $imageService->getImageUri($variant),
|
|
'width' => $width,
|
|
'descriptor' => $width . 'w',
|
|
];
|
|
}
|
|
|
|
$default = $imageService->applyProcessingInstructions(
|
|
$fileReference,
|
|
['width' => 800, 'crop' => $ref['crop'] ?? null]
|
|
);
|
|
|
|
return [
|
|
'uid' => (int)$ref['uid'],
|
|
'url' => $imageService->getImageUri($default),
|
|
'title' => (string)($ref['title'] ?? ''),
|
|
'alternative' => (string)($ref['alternative'] ?? ''),
|
|
'description' => (string)($ref['description'] ?? ''),
|
|
'srcset' => $srcset,
|
|
'properties' => [
|
|
'width' => $fileReference->getProperty('width'),
|
|
'height' => $fileReference->getProperty('height'),
|
|
'mimeType' => $fileReference->getProperty('mime_type'),
|
|
],
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FAL video (public URL only). $table as in image().
|
|
*
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public function video(int $recordUid, string $fieldName, ?string $table = null): ?array
|
|
{
|
|
try {
|
|
$table = $table ?? self::TABLE;
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_file_reference');
|
|
$ref = $qb
|
|
->select('uid', 'title', 'description')
|
|
->from('sys_file_reference')
|
|
->where(
|
|
$qb->expr()->eq('tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
|
|
$qb->expr()->eq('fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
|
|
$qb->expr()->eq('uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('deleted', 0),
|
|
$qb->expr()->eq('hidden', 0)
|
|
)
|
|
->orderBy('sorting_foreign')
|
|
->setMaxResults(1)
|
|
->executeQuery()
|
|
->fetchAssociative();
|
|
|
|
if (!$ref) {
|
|
return null;
|
|
}
|
|
|
|
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
|
|
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
|
|
|
|
return [
|
|
'uid' => (int)$ref['uid'],
|
|
'url' => $fileReference->getPublicUrl(),
|
|
'title' => (string)($ref['title'] ?? ''),
|
|
'mimeType' => $fileReference->getProperty('mime_type'),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- relations
|
|
|
|
/**
|
|
* Single market (foreign_table, no MM) -> title/slug.
|
|
*
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public function market(int $marketUid): ?array
|
|
{
|
|
if ($marketUid <= 0) {
|
|
return null;
|
|
}
|
|
try {
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('tx_vitec_domain_model_market');
|
|
$row = $qb
|
|
->select('uid', 'title', 'subtitle', 'teaser')
|
|
->from('tx_vitec_domain_model_market')
|
|
->where(
|
|
$qb->expr()->eq('uid', $qb->createNamedParameter($marketUid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('deleted', 0),
|
|
$qb->expr()->eq('hidden', 0)
|
|
)
|
|
->executeQuery()
|
|
->fetchAssociative();
|
|
|
|
if (!$row) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'uid' => (int)$row['uid'],
|
|
'title' => (string)($row['title'] ?? ''),
|
|
'subtitle' => (string)($row['subtitle'] ?? ''),
|
|
'teaser' => (string)($row['teaser'] ?? ''),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* n:n relation resolved through an MM table -> [{uid,title}, …].
|
|
*
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
public function relationMulti(string $mmTable, string $foreignTable, int $usecaseUid): array
|
|
{
|
|
try {
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable($foreignTable);
|
|
$rows = $qb
|
|
->select('f.uid', 'f.title')
|
|
->from($foreignTable, 'f')
|
|
->join('f', $mmTable, 'mm', 'mm.uid_foreign = f.uid')
|
|
->where(
|
|
$qb->expr()->eq('mm.uid_local', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('f.deleted', 0),
|
|
$qb->expr()->eq('f.hidden', 0)
|
|
)
|
|
->orderBy('mm.sorting', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
return array_map(static fn ($r) => [
|
|
'uid' => (int)$r['uid'],
|
|
'title' => (string)($r['title'] ?? ''),
|
|
], $rows);
|
|
} catch (\Throwable $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* System categories (sys_category MM) -> [{uid,title,description}, …].
|
|
*
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
public function categories(int $usecaseUid): array
|
|
{
|
|
try {
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_category');
|
|
$rows = $qb
|
|
->select('c.uid', 'c.title', 'c.description')
|
|
->from('sys_category', 'c')
|
|
->join(
|
|
'c',
|
|
'sys_category_record_mm',
|
|
'mm',
|
|
'mm.uid_local = c.uid AND mm.tablenames = ' .
|
|
$qb->createNamedParameter(self::TABLE, ParameterType::STRING) .
|
|
' AND mm.fieldname = ' .
|
|
$qb->createNamedParameter('categories', ParameterType::STRING)
|
|
)
|
|
->where(
|
|
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('c.deleted', 0),
|
|
$qb->expr()->eq('c.hidden', 0)
|
|
)
|
|
->orderBy('mm.sorting', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
return array_map(static fn ($c) => [
|
|
'uid' => (int)$c['uid'],
|
|
'title' => (string)($c['title'] ?? ''),
|
|
'description' => (string)($c['description'] ?? ''),
|
|
], $rows);
|
|
} catch (\Throwable $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------- content elements
|
|
|
|
/**
|
|
* Inline content elements (tt_content owned via tx_vitec_usecase_content).
|
|
* Flat CEs are normalised; a VITEC Columns element is resolved specially.
|
|
*
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
public function contentElements(int $usecaseUid): array
|
|
{
|
|
try {
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('tt_content');
|
|
$rows = $qb
|
|
->select('*')
|
|
->from('tt_content')
|
|
->where(
|
|
$qb->expr()->eq('tx_vitec_usecase_content', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('deleted', 0),
|
|
$qb->expr()->eq('hidden', 0)
|
|
)
|
|
->orderBy('sorting', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
$out = [];
|
|
foreach ($rows as $row) {
|
|
$resolved = $this->resolveInlineElement($row);
|
|
if ($resolved !== null) {
|
|
$out[] = $resolved;
|
|
}
|
|
}
|
|
return $out;
|
|
} catch (\Throwable $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $row
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
private function resolveInlineElement(array $row): ?array
|
|
{
|
|
if ((string)($row['CType'] ?? '') === self::COLUMNS_CTYPE) {
|
|
return $this->resolveColumnsElement($row);
|
|
}
|
|
return ContentElementResolver::normaliseRecord($row);
|
|
}
|
|
|
|
/**
|
|
* VITEC Columns content block -> { header…, layout, columns:{left,right} }.
|
|
* Items live in a content-blocks collection table (discovered from TCA) and
|
|
* carry a `column` (left/right) plus a typed payload.
|
|
*
|
|
* @param array<string,mixed> $row
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function resolveColumnsElement(array $row): array
|
|
{
|
|
$uid = (int)$row['uid'];
|
|
|
|
$left = [];
|
|
$right = [];
|
|
|
|
$table = $this->collectionTable('vitec_items') ?? $this->collectionTable('items');
|
|
if ($table !== null) {
|
|
try {
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
|
$items = $qb
|
|
->select('*')
|
|
->from($table)
|
|
->where(
|
|
$qb->expr()->eq('foreign_table_parent_uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('deleted', 0),
|
|
$qb->expr()->eq('hidden', 0)
|
|
)
|
|
->orderBy('sorting', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
foreach ($items as $it) {
|
|
$column = (string)($it['vitec_column'] ?? $it['column'] ?? 'left');
|
|
$resolved = $this->resolveColumnItem($it, $table);
|
|
if ($column === 'right') {
|
|
$right[] = $resolved;
|
|
} else {
|
|
$left[] = $resolved;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// collection table not ready / query failed -> empty columns
|
|
}
|
|
}
|
|
|
|
return [
|
|
'id' => $uid,
|
|
'type' => self::COLUMNS_CTYPE,
|
|
'colPos' => (int)($row['colPos'] ?? 0),
|
|
'sorting' => (int)($row['sorting'] ?? 0),
|
|
'header' => (string)($row['header'] ?? ''),
|
|
'subheader' => (string)($row['subheader'] ?? ''),
|
|
'headerLayout' => (int)($row['header_layout'] ?? 0),
|
|
'headerPosition' => (string)($row['header_position'] ?? ''),
|
|
'headerLink' => (string)($row['header_link'] ?? ''),
|
|
'layout' => (string)($row['vitec_layout'] ?? $row['layout'] ?? 'cols_50_50'),
|
|
'columns' => [
|
|
'left' => $left,
|
|
'right' => $right,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $it
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function resolveColumnItem(array $it, string $table): array
|
|
{
|
|
$uid = (int)$it['uid'];
|
|
$get = static fn (string $k): string => (string)($it['vitec_' . $k] ?? $it[$k] ?? '');
|
|
|
|
$imageField = array_key_exists('vitec_image', $it) ? 'vitec_image' : 'image';
|
|
$videoField = array_key_exists('vitec_video', $it) ? 'vitec_video' : 'video';
|
|
|
|
return [
|
|
'type' => $get('item_type') !== '' ? $get('item_type') : 'text',
|
|
'headline' => $get('headline'),
|
|
'text' => $get('text'),
|
|
'image' => $this->image($uid, $imageField, $table),
|
|
'video' => $this->video($uid, $videoField, $table),
|
|
'link' => $get('link'),
|
|
'linkLabel' => $get('link_label'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Resolve a content-blocks collection field's storage table from TCA.
|
|
*/
|
|
private function collectionTable(string $field): ?string
|
|
{
|
|
$t = $GLOBALS['TCA']['tt_content']['columns'][$field]['config']['foreign_table'] ?? null;
|
|
return (is_string($t) && $t !== '') ? $t : null;
|
|
}
|
|
|
|
// ------------------------------------------------------------------- seo
|
|
|
|
/**
|
|
* SEO bundle with fallback resolution (title/teaser/card/hero images).
|
|
*
|
|
* @param array<string,mixed> $u
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function seo(array $u): array
|
|
{
|
|
$uid = (int)$u['uid'];
|
|
|
|
$title = $this->firstNonEmpty([(string)($u['seo_title'] ?? ''), (string)($u['title'] ?? '')]);
|
|
$desc = $this->firstNonEmpty([(string)($u['seo_description'] ?? ''), (string)($u['teaser'] ?? '')]);
|
|
|
|
$ogTitle = $this->firstNonEmpty([(string)($u['og_title'] ?? ''), $title]);
|
|
$ogDesc = $this->firstNonEmpty([(string)($u['og_description'] ?? ''), $desc]);
|
|
$ogImage = $this->image($uid, 'og_image')
|
|
?? $this->image($uid, 'card_image')
|
|
?? $this->image($uid, 'hero_bgimage');
|
|
|
|
$twTitle = $this->firstNonEmpty([(string)($u['twitter_title'] ?? ''), $ogTitle]);
|
|
$twDesc = $this->firstNonEmpty([(string)($u['twitter_description'] ?? ''), $ogDesc]);
|
|
$twImage = $this->image($uid, 'twitter_image') ?? $ogImage;
|
|
|
|
return [
|
|
'title' => $title,
|
|
'description' => $desc,
|
|
'canonical' => (string)($u['canonical_link'] ?? ''),
|
|
'robots' => [
|
|
'noIndex' => (bool)($u['no_index'] ?? false),
|
|
'noFollow' => (bool)($u['no_follow'] ?? false),
|
|
],
|
|
'openGraph' => [
|
|
'title' => $ogTitle,
|
|
'description' => $ogDesc,
|
|
'image' => $ogImage,
|
|
],
|
|
'twitter' => [
|
|
'title' => $twTitle,
|
|
'description' => $twDesc,
|
|
'image' => $twImage,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int,string> $candidates
|
|
*/
|
|
private function firstNonEmpty(array $candidates): string
|
|
{
|
|
foreach ($candidates as $c) {
|
|
$c = trim($c);
|
|
if ($c !== '') {
|
|
return $c;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
}
|