Checkpoint: headless JSON architecture documented + cleanup (Stufe 1+2)

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>
This commit is contained in:
o-rasche
2026-07-09 10:01:56 +02:00
parent c6759186cb
commit bb7c03235d
237 changed files with 6698 additions and 1749 deletions

View File

@@ -0,0 +1,530 @@
<?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 '';
}
}