*/ private const VALID_KINDS = array( self::KIND_SINGLE, ); /** * Persistence port that backs the §10.1 lookup. */ private ProductRepository $repository; /** * §9.1 view-model factory wired at construction. */ private ProductCardViewModelFactory $viewModelFactory; /** * §6.5 renderer wired at construction. */ private ProductCardRenderer $renderer; /** * §8.2 / §8.4 view-model array dumper wired at construction. */ private ViewModelArrayDumper $arrayDumper; /** * Construct the UseCase with explicit collaborators. * * @param ProductRepository $repository Persistence port. * @param ProductCardViewModelFactory $viewModelFactory §9.1 factory. * @param ProductCardRenderer $renderer §6.5 renderer. * @param ViewModelArrayDumper $arrayDumper §8.2 / §8.4 dumper. */ public function __construct( ProductRepository $repository, ProductCardViewModelFactory $viewModelFactory, ProductCardRenderer $renderer, ViewModelArrayDumper $arrayDumper ) { $this->repository = $repository; $this->viewModelFactory = $viewModelFactory; $this->renderer = $renderer; $this->arrayDumper = $arrayDumper; } /** * Execute the §10.1 preview dispatch. * * Validation summary (each violation raises * {@see InvalidArgumentException}): * * - `$kind` MUST be a member of {@see self::VALID_KINDS}. * - `$productIds` MUST be a non-empty list of non-empty * strings; for `'single'` the list MUST have length 1. * - `$productIds[0]` MUST parse as a positive integer string. * - `$template` MUST be non-empty after trimming. * - `$userId` MUST be `>= 1`. * * Lookup: when validation passes the use case resolves the * persisted product via the repository and raises * {@see ProductNotFoundException::forId()} when no row * matches. * * @param string $kind §10.1 preview kind (`'single'` only in Phase-6 minimum). * @param list $productIds Product id list (length 1 for `'single'`). * @param string $template §6.4 template key. * @param array $attrs Block attributes (permissive subset). * @param int $userId User id for cache-key composition. * * @throws ProductNotFoundException When the resolved product id * does not match any persisted * row. Per-field validation * failures (raised by the * private `assert*` helpers * and the {@see self::parseProductId()} * helper) surface as * {@see InvalidArgumentException} * but are not listed here * because the literal `throw` * in this method body is the * not-found case. */ public function execute( string $kind, array $productIds, string $template, array $attrs, int $userId ): PreviewResult { $this->assertKind( $kind ); $this->assertProductIds( $kind, $productIds ); $this->assertTemplate( $template ); $this->assertUserId( $userId ); $productId = $this->parseProductId( $productIds[0] ); $record = $this->repository->findById( $productId ); if ( $record === null ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw ProductNotFoundException::forId( $productId ); } $snapshot = SnapshotProjector::project( $record ); $config = self::buildEmbedConfig( $template, $attrs ); $context = RenderContext::editor( $template ); $viewModel = $this->viewModelFactory->build( $snapshot, $config, $context, array() ); // PreviewResult applies the server-side sanitization required by // rest-api-spec.md §10.1 before the HTML reaches the editor. $html = $this->renderer->render( $viewModel, $context ); $viewArray = $this->arrayDumper->dump( $viewModel ); $cacheKey = self::composeCacheKey( $userId, $kind, $productIds, $template ); // Phase-6 minimum: no preview cache layer wired yet, so the // hit flag is always false. See the file-level docblock. return new PreviewResult( $viewArray, $html, $cacheKey, false ); } /** * Validate the `$kind` axis against the §10.1 closed set. * * @param string $kind Raw kind token. * * @throws InvalidArgumentException When `$kind` is not a * member of the closed set. */ private function assertKind( string $kind ): void { if ( ! in_array( $kind, self::VALID_KINDS, true ) ) { $message = sprintf( 'PreviewProductCardUseCase: kind must be one of [%s]; got "%s".', implode( ', ', self::VALID_KINDS ), $kind ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Validate the `$productIds` axis against the §10.1 contract. * * @param string $kind Validated kind token. * @param list $productIds Raw product id list. * * @throws InvalidArgumentException When `$productIds` is empty, * carries a non-string or * empty entry, or when the * length does not match * `$kind`. */ private function assertProductIds( string $kind, array $productIds ): void { if ( $productIds === array() ) { $message = 'PreviewProductCardUseCase: productIds must be a non-empty list.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $expected = 0; foreach ( $productIds as $index => $value ) { if ( $index !== $expected ) { $message = 'PreviewProductCardUseCase: productIds must be a zero-based contiguous list.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! is_string( $value ) || trim( $value ) === '' ) { $message = 'PreviewProductCardUseCase: productIds entries must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } ++$expected; } if ( $kind === self::KIND_SINGLE && count( $productIds ) !== 1 ) { $message = sprintf( 'PreviewProductCardUseCase: kind "single" requires exactly one productId; got %d.', count( $productIds ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Validate the `$template` axis. * * @param string $template Raw template key. * * @throws InvalidArgumentException When `$template` is empty * after trimming. */ private function assertTemplate( string $template ): void { if ( trim( $template ) === '' ) { $message = 'PreviewProductCardUseCase: template must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Validate the `$userId` axis. * * @param int $userId Raw user id. * * @throws InvalidArgumentException When `$userId` is below 1. */ private function assertUserId( int $userId ): void { if ( $userId < 1 ) { $message = sprintf( 'PreviewProductCardUseCase: userId must be >= 1; got %d.', $userId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Parse a product-id string into a positive integer. * * @param string $raw Raw product-id string. * * @throws InvalidArgumentException When `$raw` does not parse * as a positive integer. */ private function parseProductId( string $raw ): int { $parsed = filter_var( trim( $raw ), FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); if ( $parsed === false ) { $message = 'PreviewProductCardUseCase: productId must be a positive integer string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $parsed; } /** * Build the §10.1 {@see EmbedConfigInput} from the supplied * template key and the permissive `$attrs` subset. * * Honored `$attrs` keys (when present and boolean-typed): * * - `show_price` → {@see MetaFlags::withShowPrice()} * - `show_brand` → {@see MetaFlags::withShowBrand()} * - `show_description` → {@see MetaFlags::withShowDescription()} * - `show_provider_badge` → {@see MetaFlags::withShowProviderBadge()} * * Unknown keys and non-boolean values are silently ignored * per the §10.1 permissive-subset note. * * @param string $template §6.4 template key. * @param array $attrs Block attributes. */ private static function buildEmbedConfig( string $template, array $attrs ): EmbedConfigInput { $meta = MetaFlags::defaults(); if ( array_key_exists( 'show_price', $attrs ) && is_bool( $attrs['show_price'] ) ) { $meta = $meta->withShowPrice( $attrs['show_price'] ); } if ( array_key_exists( 'show_brand', $attrs ) && is_bool( $attrs['show_brand'] ) ) { $meta = $meta->withShowBrand( $attrs['show_brand'] ); } if ( array_key_exists( 'show_description', $attrs ) && is_bool( $attrs['show_description'] ) ) { $meta = $meta->withShowDescription( $attrs['show_description'] ); } if ( array_key_exists( 'show_provider_badge', $attrs ) && is_bool( $attrs['show_provider_badge'] ) ) { $meta = $meta->withShowProviderBadge( $attrs['show_provider_badge'] ); } // Phase-6 minimum: buttonConfigs is empty; preview shows a // static card without affiliate-tracked buttons. See the // file-level docblock. return new EmbedConfigInput( $template, array(), $meta, null, false ); } /** * Compose the §10.1 preview cache key. * * Form: `affilun:render:preview:{userId}:{kind}:{ids}:{template}:v1` * where `{ids}` is the comma-joined `$productIds` list. The * `userId` segment scopes the cache per author so a preview * never leaks across user boundaries. * * @param int $userId User id for cache scoping. * @param string $kind Validated kind token. * @param list $productIds Validated product-id list. * @param string $template Validated template key. */ private static function composeCacheKey( int $userId, string $kind, array $productIds, string $template ): string { return 'affilun:render:preview:' . $userId . ':' . $kind . ':' . implode( ',', $productIds ) . ':' . $template . ':v1'; } }