diff --git a/migrations/add_product_check_element_20261112.php b/migrations/add_product_check_element_20261112.php new file mode 100644 index 0000000..05d6de9 --- /dev/null +++ b/migrations/add_product_check_element_20261112.php @@ -0,0 +1,25 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); + +$page = $pdo->query("SELECT uid, title, slug FROM pages WHERE uid = $pageUid AND deleted = 0")->fetch(); +if ($page === false) { exit("page $pageUid not found\n"); } +echo "page #{$page['uid']} {$page['title']} ({$page['slug']})\n"; + +$existing = $pdo->query("SELECT uid, hidden FROM tt_content WHERE pid = $pageUid AND CType = 'vitec_productcheck' AND deleted = 0")->fetch(); +if ($existing !== false) { echo "element already there: #{$existing['uid']} (hidden={$existing['hidden']})\n"; exit(0); } + +$now = time(); +$stmt = $pdo->prepare('INSERT INTO tt_content (pid, tstamp, crdate, sorting, CType, colPos, header, header_layout, sys_language_uid, hidden, deleted) + VALUES (:pid, :now, :now, 256, :ctype, 0, :header, 0, 0, 0, 0)'); +$stmt->execute([':pid' => $pageUid, ':now' => $now, ':ctype' => 'vitec_productcheck', ':header' => 'Product Content Check']); +echo "inserted tt_content #" . $pdo->lastInsertId() . " (vitec_productcheck, colPos 0)\n"; diff --git a/packages/vitec/Classes/Service/ProductCheckService.php b/packages/vitec/Classes/Service/ProductCheckService.php new file mode 100644 index 0000000..cca8b4d --- /dev/null +++ b/packages/vitec/Classes/Service/ProductCheckService.php @@ -0,0 +1,395 @@ + 'hero', 'label' => 'Hero', 'kind' => 'images'], + ['key' => 'photos', 'label' => 'Photos', 'kind' => 'images'], + ['key' => 'og', 'label' => 'OG image', 'kind' => 'images'], + ['key' => 'video', 'label' => 'Video', 'kind' => 'video'], + ['key' => 'teaser', 'label' => 'Teaser', 'kind' => 'text'], + ['key' => 'subtitle', 'label' => 'Subtitle', 'kind' => 'text'], + ['key' => 'description', 'label' => 'Description', 'kind' => 'text'], + ['key' => 'description2', 'label' => 'Description 2', 'kind' => 'text'], + ['key' => 'highlights', 'label' => 'Highlights', 'kind' => 'text'], + ['key' => 'capabilities', 'label' => 'Capabilities', 'kind' => 'text'], + ['key' => 'applications', 'label' => 'Applications', 'kind' => 'text'], + ['key' => 'features', 'label' => 'Features', 'kind' => 'features'], + ['key' => 'resources', 'label' => 'Resources', 'kind' => 'list'], + ['key' => 'datasheets', 'label' => 'Datasheets', 'kind' => 'list'], + ['key' => 'solutions', 'label' => 'Solutions', 'kind' => 'list'], + ]; + + private const FLAGS = [ + 'hidden' => 'hidden', + 'legacy' => 'legacy', + 'supportproduct' => 'support', + 'subproduct' => 'sub', + 'hideonwebsite' => 'hideWeb', + 'hideonproducts' => 'hideList', + 'showdatapath' => 'datapath', + ]; + + private const TEXT_FIELDS = [ + 'teaser' => 'Teaser', + 'subtitle' => 'Subtitle', + 'description' => 'Description', + 'description2' => 'Description 2', + 'highlights' => 'Highlights', + 'capabilities' => 'Capabilities', + 'applications' => 'Applications', + ]; + + public function __construct( + private readonly ConnectionPool $connectionPool, + ) {} + + /** + * @return array{generatedAt: string, fields: array, summary: array{products: int, filled: array}, products: array>} + */ + public function overview(): array + { + $products = $this->products(); + $uids = array_map(static fn(array $p): int => (int)$p['uid'], $products); + $files = $this->fileCounts($uids); + $features = $this->featureCounts($uids); + $resources = $this->countBy('tx_vitec_product_resource', 'product', 'deleted = 0 AND hidden = 0'); + $datasheets = $this->mmCounts('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download'); + $solutions = $this->mmCounts('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution'); + + $filled = array_fill_keys(array_column(self::FIELDS, 'key'), 0); + $rows = []; + foreach ($products as $p) { + $uid = (int)$p['uid']; + $f = $files[$uid] ?? []; + $checks = [ + 'hero' => $this->check($f['heroimage'] ?? 0), + 'photos' => $this->check($f['productimage'] ?? 0), + 'og' => $this->check($f['ogimage'] ?? 0), + 'video' => $this->check($f['videofile'] ?? 0), + 'teaser' => $this->check($this->filled($p['teaser']) ? 1 : 0), + 'subtitle' => $this->check($this->filled($p['subtitle']) ? 1 : 0), + 'description' => $this->check($this->filled($p['description']) ? 1 : 0), + 'description2' => $this->check($this->filled($p['description2']) ? 1 : 0), + 'highlights' => $this->check($this->filled($p['highlights']) ? 1 : 0), + 'capabilities' => $this->check($this->filled($p['capabilities']) ? 1 : 0), + 'applications' => $this->check($this->filled($p['applications']) ? 1 : 0), + 'features' => $this->check($features[$uid]['cards'] ?? 0) + ['icons' => $features[$uid]['icons'] ?? 0], + 'resources' => $this->check($resources[$uid] ?? 0), + 'datasheets' => $this->check($datasheets[$uid] ?? 0), + 'solutions' => $this->check($solutions[$uid] ?? 0), + ]; + foreach ($checks as $key => $c) { + if ($c['ok']) { + $filled[$key]++; + } + } + $rows[] = $this->base($p) + ['checks' => $checks]; + } + + return [ + 'generatedAt' => date(\DATE_ATOM), + 'fields' => self::FIELDS, + 'summary' => ['products' => count($rows), 'filled' => $filled], + 'products' => $rows, + ]; + } + + /** + * @return array|null null when the product does not exist + */ + public function detail(int $uid): ?array + { + $p = $this->connection()->executeQuery( + 'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights, + capabilities, applications, resources_layout, + hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath + FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 AND uid = ?', + [$uid] + )->fetchAssociative(); + if ($p === false) { + return null; + } + + $files = $this->files($uid); + $texts = []; + foreach (self::TEXT_FIELDS as $field => $label) { + $texts[$field] = [ + 'label' => $label, + 'ok' => $this->filled($p[$field]), + 'html' => (string)$p[$field], + ]; + } + + return $this->base($p) + [ + 'images' => [ + 'hero' => $files['heroimage'] ?? [], + 'photos' => $files['productimage'] ?? [], + 'og' => $files['ogimage'] ?? [], + ], + 'videos' => array_map(static fn(array $v): array => ['url' => $v['url'], 'name' => $v['name']], $files['videofile'] ?? []), + 'texts' => $texts, + 'features' => $this->features($uid), + 'resources' => $this->resources($uid), + 'datasheets' => $this->mmTitles('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download', $uid), + 'solutions' => $this->mmTitles('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution', $uid), + ]; + } + + // ---- building blocks ---------------------------------------------------- + + /** @param array $p */ + private function base(array $p): array + { + $slug = trim((string)$p['slug'], '/'); + $flags = []; + foreach (self::FLAGS as $column => $label) { + if ((int)($p[$column] ?? 0) === 1) { + $flags[] = $label; + } + } + return [ + 'uid' => (int)$p['uid'], + 'title' => (string)$p['title'], + 'slug' => $slug, + 'url' => '/product/' . $slug, + 'flags' => $flags, + 'resourcesLayout' => (string)($p['resources_layout'] ?? ''), + ]; + } + + /** @return array{ok: bool, count: int} */ + private function check(int $count): array + { + return ['ok' => $count > 0, 'count' => $count]; + } + + private function filled(mixed $value): bool + { + return trim(strip_tags((string)$value)) !== ''; + } + + /** @return array> */ + private function products(): array + { + return $this->connection()->executeQuery( + 'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights, + capabilities, applications, resources_layout, + hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath + FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 ORDER BY LOWER(title), uid' + )->fetchAllAssociative(); + } + + /** + * Visible FAL references per product and field. + * + * @param int[] $uids + * @return array> [productUid][fieldname] = count + */ + private function fileCounts(array $uids): array + { + if ($uids === []) { + return []; + } + $rows = $this->connection()->executeQuery( + "SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference + WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tx_vitec_domain_model_product' + AND fieldname IN ('heroimage','productimage','ogimage','videofile') + AND uid_foreign IN (" . implode(',', array_map('intval', $uids)) . ') + GROUP BY uid_foreign, fieldname' + )->fetchAllAssociative(); + $out = []; + foreach ($rows as $r) { + $out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n']; + } + return $out; + } + + /** + * Files of one product with original URL and a ~480 px thumbnail + * (SVG and non-images keep the original as thumbnail). + * + * @return array> + */ + private function files(int $uid): array + { + $rows = $this->connection()->executeQuery( + "SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type + FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local + WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product' + AND r.fieldname IN ('heroimage','productimage','ogimage','videofile') AND r.uid_foreign = ? + ORDER BY r.fieldname, r.sorting_foreign", + [$uid] + )->fetchAllAssociative(); + $out = []; + foreach ($rows as $r) { + $url = '/fileadmin' . $r['identifier']; + $out[(string)$r['fieldname']][] = [ + 'url' => $url, + 'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url), + 'name' => (string)$r['name'], + 'mime' => (string)$r['mime_type'], + ]; + } + return $out; + } + + private function thumbnail(int $fileUid, string $mime, string $fallback): string + { + if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') { + return $fallback; + } + try { + $file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid); + $imageService = GeneralUtility::makeInstance(ImageService::class); + $processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]); + return $imageService->getImageUri($processed); + } catch (\Throwable) { + return $fallback; + } + } + + /** + * @param int[] $uids + * @return array + */ + private function featureCounts(array $uids): array + { + if ($uids === []) { + return []; + } + $in = implode(',', array_map('intval', $uids)); + $out = []; + foreach ($this->connection()->executeQuery( + "SELECT product, COUNT(*) AS n FROM tx_vitec_product_feature + WHERE deleted = 0 AND hidden = 0 AND product IN ($in) GROUP BY product" + )->fetchAllAssociative() as $r) { + $out[(int)$r['product']] = ['cards' => (int)$r['n'], 'icons' => 0]; + } + foreach ($this->connection()->executeQuery( + "SELECT fe.product, COUNT(DISTINCT fe.uid) AS n + FROM tx_vitec_product_feature fe + JOIN sys_file_reference r ON r.uid_foreign = fe.uid AND r.tablenames = 'tx_vitec_product_feature' + AND r.fieldname = 'icon' AND r.deleted = 0 AND r.hidden = 0 + WHERE fe.deleted = 0 AND fe.hidden = 0 AND fe.product IN ($in) GROUP BY fe.product" + )->fetchAllAssociative() as $r) { + $out[(int)$r['product']]['icons'] = (int)$r['n']; + } + return $out; + } + + /** @return array */ + private function features(int $uid): array + { + $cards = $this->connection()->executeQuery( + 'SELECT uid, title, cardtext FROM tx_vitec_product_feature + WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid', + [$uid] + )->fetchAllAssociative(); + if ($cards === []) { + return []; + } + $icons = []; + foreach ($this->connection()->executeQuery( + "SELECT r.uid_foreign, f.identifier, f.name FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local + WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon' + AND r.uid_foreign IN (" . implode(',', array_map(static fn(array $c): int => (int)$c['uid'], $cards)) . ') + ORDER BY r.uid_foreign, r.sorting_foreign' + )->fetchAllAssociative() as $r) { + $icons[(int)$r['uid_foreign']] ??= ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']]; + } + return array_map(static fn(array $c): array => [ + 'title' => (string)$c['title'], + 'text' => (string)$c['cardtext'], + 'icon' => $icons[(int)$c['uid']] ?? null, + ], $cards); + } + + /** @return array */ + private function resources(int $uid): array + { + return array_map(static fn(array $r): array => [ + 'title' => (string)$r['title'], + 'type' => (string)$r['restype'], + 'link' => (string)$r['link'], + 'text' => (string)$r['cardtext'], + ], $this->connection()->executeQuery( + 'SELECT title, restype, link, cardtext FROM tx_vitec_product_resource + WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid', + [$uid] + )->fetchAllAssociative()); + } + + /** @return array [productUid] = count */ + private function countBy(string $table, string $column, string $where): array + { + $out = []; + foreach ($this->connection()->executeQuery( + "SELECT $column AS k, COUNT(*) AS n FROM $table WHERE $where GROUP BY $column" + )->fetchAllAssociative() as $r) { + $out[(int)$r['k']] = (int)$r['n']; + } + return $out; + } + + /** @return array [productUid] = count of non-deleted foreign records */ + private function mmCounts(string $mmTable, string $foreignTable): array + { + $out = []; + foreach ($this->connection()->executeQuery( + "SELECT mm.uid_local AS k, COUNT(*) AS n FROM $mmTable mm + JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0 + GROUP BY mm.uid_local" + )->fetchAllAssociative() as $r) { + $out[(int)$r['k']] = (int)$r['n']; + } + return $out; + } + + /** @return array */ + private function mmTitles(string $mmTable, string $foreignTable, int $uid): array + { + return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $this->connection()->executeQuery( + "SELECT t.uid, t.title FROM $mmTable mm JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0 + WHERE mm.uid_local = ? ORDER BY mm.sorting", + [$uid] + )->fetchAllAssociative()); + } + + private function connection(): \TYPO3\CMS\Core\Database\Connection + { + return $this->connectionPool->getConnectionForTable(self::PRODUCT_TABLE); + } +} diff --git a/packages/vitec/Classes/UserFunc/ProductCheckJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductCheckJsonRenderer.php new file mode 100644 index 0000000..480fdb1 --- /dev/null +++ b/packages/vitec/Classes/UserFunc/ProductCheckJsonRenderer.php @@ -0,0 +1,59 @@ + {mode: "detail", product: {…} | null} + * on failure {mode: "error", error} + * + * Always USER_INT: the audit must show the database as it is right now, + * so neither the TYPO3 page cache nor the `check` variant may stick. + * `check` is excluded from the cHash in ext_localconf.php. The front end + * is expected to load the detail variant client-side from /_json (one + * request per opened row), not to navigate the SSR page with the query. + */ +final class ProductCheckJsonRenderer +{ + protected ?ContentObjectRenderer $cObj = null; + + public function setContentObjectRenderer(ContentObjectRenderer $cObj): void + { + $this->cObj = $cObj; + } + + #[AsAllowedCallable] + public function render(string $content, array $conf): string + { + if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) { + $this->cObj->convertToUserIntObject(); + return ''; + } + try { + $service = GeneralUtility::makeInstance(ProductCheckService::class); + $queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? []; + $check = (int)($queryParams['check'] ?? 0); + if ($check > 0) { + $payload = ['mode' => 'detail', 'product' => $service->detail($check)]; + } else { + $payload = ['mode' => 'overview'] + $service->overview(); + } + } catch (\Throwable $e) { + $payload = ['mode' => 'error', 'error' => $e->getMessage()]; + } + return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + } +} diff --git a/packages/vitec/Configuration/Services.yaml b/packages/vitec/Configuration/Services.yaml index d71471d..b031bae 100644 --- a/packages/vitec/Configuration/Services.yaml +++ b/packages/vitec/Configuration/Services.yaml @@ -28,6 +28,11 @@ services: Evomedien\Vitec\Service\SsrPurgeService: public: true + # Fetched via GeneralUtility::makeInstance from the USER func + # ProductCheckJsonRenderer (TypoScript userFunc, no DI there). + Evomedien\Vitec\Service\ProductCheckService: + public: true + # SSR dashboard tiles (the WidgetConfigurationInterface argument is # injected by the dashboard compiler pass via the tag). Evomedien\Vitec\Widgets\SsrStatusWidget: diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index e7b68ce..18b8607 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -100,6 +100,20 @@ tt_content { } } + # Product Content Check - the product audit table as data (page + # /products/product-check). Always USER_INT, see ProductCheckJsonRenderer. + vitec_productcheck < lib.contentElementWithHeader + vitec_productcheck { + fields { + content { + fields { + productcheck = USER + productcheck.userFunc = Evomedien\Vitec\UserFunc\ProductCheckJsonRenderer->render + } + } + } + } + # EXT:solr search results as JSON - the search endpoint for the React frontend solr_pi_results < lib.contentElementWithHeader solr_pi_results { diff --git a/packages/vitec/Configuration/TCA/Overrides/tt_content.php b/packages/vitec/Configuration/TCA/Overrides/tt_content.php index cd7ec39..f224fd9 100644 --- a/packages/vitec/Configuration/TCA/Overrides/tt_content.php +++ b/packages/vitec/Configuration/TCA/Overrides/tt_content.php @@ -27,6 +27,16 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility; $registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist'); $registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist'); $registerPluginWithFlexForm('Productfeature', 'Featured Product Card', 'FILE:EXT:vitec/Configuration/FlexForms/Productfeature.xml', 'vitec-plugin-productlist'); + // Product content audit as page content (2026-11-12): no FlexForm, the + // element has nothing to configure - data comes from ProductCheckService. + ExtensionUtility::registerPlugin( + 'Vitec', + 'Productcheck', + 'Product Content Check (audit table)', + 'vitec-plugin-productlist', + 'plugins', + 'Live audit of every product record: one row per product, one check per content field, details per row. Place once on /products/product-check.' + ); $registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard'); $registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow'); $registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow'); diff --git a/packages/vitec/Configuration/page.tsconfig b/packages/vitec/Configuration/page.tsconfig index 2cfc528..aef489e 100755 --- a/packages/vitec/Configuration/page.tsconfig +++ b/packages/vitec/Configuration/page.tsconfig @@ -32,6 +32,14 @@ mod { CType = vitec_productfinder } } + productcheck { + iconIdentifier = vitec-plugin-productlist + title = Product Content Check (audit table) + description = Live audit of all product records - one row per product, one check per content field. Internal page /products/product-check. + tt_content_defValues { + CType = vitec_productcheck + } + } productshow { iconIdentifier = vitec-plugin-productshow title = Show single VITEC Product diff --git a/packages/vitec/ext_localconf.php b/packages/vitec/ext_localconf.php index 90ad5be..3e89517 100755 --- a/packages/vitec/ext_localconf.php +++ b/packages/vitec/ext_localconf.php @@ -268,6 +268,9 @@ $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category'; +// Product content audit (/products/product-check): ?check= +// switches the vitec_productcheck element to the detail payload. +$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'check'; // SSR auto-purge: editors' changes evict the matching pages from the SSR // node's full-page cache (queued per request, one call per save/import).