provider()`. * - `providerItemId` ← `$snapshot->providerProductId()->value()`. * - `canonicalHash` ← computed via the §4.4 pseudocode (`ch1:` * prefix + SHA-256 over normalized title / brand / first * image-url-without-query). The Phase 2 schema requires the * column be non-empty; computing it here keeps the UseCase * deterministic without depending on a yet-to-land Phase 9 * hash service. * - `title` ← `$snapshot->title()`. * - `brand` ← `$snapshot->brand()`. * - `description` ← `$snapshot->description()`. * - `imagePrimaryUrl` ← `$snapshot->primaryImage()`. * - `imageGalleryJson` ← JSON-encoded * `$snapshot->galleryImages()` (null when the list is empty). * - `priceAmount` / `priceCurrency` / `priceLabel` ← extracted * from `$snapshot->price()` when non-null; all three null * when the snapshot carries no price. * - `availabilityStatus` ← `$snapshot->availability()->status()`. * - `detailUrl` ← `$snapshot->detailUrl()`. * - `affiliateUrl` ← `$snapshot->affiliateUrl()`. * - `rawPayloadJson` ← null at this layer; the Phase 9 sync * pipeline owns the raw-payload column. * - `snapshotVersion` ← `1` on insert (per §4.2 default), reused * verbatim on update (the §4.2 column has no automatic-bump * contract; future migrations land as separate UseCases). * * TODO Phase 6 minimum does not handle the optional If-Match * conflict (§8.4 mentions an opt-in 409 path for clients that send * `If-Match` with an ETag/`updated_at` hash). The Controller will * examine the request header and raise outside this UseCase if * needed; this UseCase always upserts unconditionally. * * @package Affilun\Application\Product\UseCase */ declare(strict_types=1); namespace Affilun\Application\Product\UseCase; use Affilun\Application\Product\FallbackProductSearchKeywordsExtractor; use Affilun\Application\Product\ProductSearchKeywordsExtractor; use Affilun\Application\Product\ProductUrlDraft; use Affilun\Domain\Clock; use Affilun\Domain\Pricing\PriceInfo; use Affilun\Domain\Product\ProductSnapshot; use Affilun\Infrastructure\Persistence\ProductRecord; use Affilun\Infrastructure\Persistence\ProductRepository; use Affilun\Infrastructure\Persistence\ProductUrlRecord; use Affilun\Infrastructure\Persistence\ProductUrlRepository; /** * Phase 6 UseCase that drives the §8.4 `POST /affilun/v1/products` * endpoint. * * See the file-level docblock for the upsert decision flow, the * field-mapping rules from {@see ProductSnapshot} to * {@see ProductRecord}, and the §6.4 sync-stamping rule that * binds the timestamp columns. */ final class SaveProductUseCase { /** * Persistence port that backs the §8.4 upsert. */ private ProductRepository $repository; /** * Clock port used to stamp the §6.4 sync timestamps. */ private Clock $clock; /** * Extracts cross-provider search keywords before persistence. */ private ProductSearchKeywordsExtractor $keywordExtractor; /** * Optional child-URL repository for the affilun product ID model. */ private ?ProductUrlRepository $urlRepository; /** * Construct the UseCase with explicit repository and clock * bindings. * * @param ProductRepository $repository Persistence port. * @param Clock $clock Clock port for §6.4 sync timestamps. * @param ProductSearchKeywordsExtractor|null $keywordExtractor Optional search-keyword extractor. * @param ProductUrlRepository|null $urlRepository Optional product URL repository. */ public function __construct( ProductRepository $repository, Clock $clock, ?ProductSearchKeywordsExtractor $keywordExtractor = null, ?ProductUrlRepository $urlRepository = null ) { $this->repository = $repository; $this->clock = $clock; $this->keywordExtractor = $keywordExtractor ?? new FallbackProductSearchKeywordsExtractor(); $this->urlRepository = $urlRepository; } /** * Execute the §8.4 upsert dispatch. * * Looks up the existing row by * `(provider, providerProductId)`; on hit, rebuilds the * matching {@see ProductRecord} preserving `id` and * `createdAt` while refreshing every other column from the * snapshot and the clock. On miss, builds a fresh record with * `id = null` and saves through the repository to obtain the * assigned auto-increment primary key. * * @param ProductSnapshot $snapshot Normalized snapshot to * persist. * @param array $urlDrafts Additional URL drafts. * * @return SaveProductResult Post-save record plus the * insert-vs-update flag. */ public function execute( ProductSnapshot $snapshot, array $urlDrafts = array() ): SaveProductResult { $now = $this->clock->now(); $drafts = $this->normalizeUrlDrafts( $snapshot, $urlDrafts ); $existing = $this->findExistingAmazonProduct( $snapshot ) ?? $this->repository->findByProviderItem( $snapshot->provider(), $snapshot->providerProductId()->value() ); if ( $existing === null ) { $record = $this->buildNewRecord( $snapshot, $now ); $persisted = $this->repository->save( $record ); $id = $persisted->id(); if ( $id !== null ) { $this->persistUrlDrafts( $id, $drafts, $now ); } return new SaveProductResult( $persisted, true ); } $record = $this->buildUpdatedRecord( $existing, $snapshot, $now ); $persisted = $this->repository->save( $record ); $id = $persisted->id(); if ( $id !== null ) { $this->persistUrlDrafts( $id, $drafts, $now ); } return new SaveProductResult( $persisted, false ); } /** * Normalize URL drafts and add the selected provider page itself. * * @param ProductSnapshot $snapshot Normalized snapshot to persist. * @param array $urlDrafts Additional URL drafts. * * @return list */ private function normalizeUrlDrafts( ProductSnapshot $snapshot, array $urlDrafts ): array { $drafts = array( new ProductUrlDraft( $snapshot->provider(), $snapshot->providerProductId()->value(), $snapshot->detailUrl(), 'provider' ), ); if ( $snapshot->affiliateUrl() !== null ) { $drafts[] = new ProductUrlDraft( $snapshot->provider(), $snapshot->providerProductId()->value(), $snapshot->affiliateUrl(), 'provider' ); } foreach ( $urlDrafts as $draft ) { if ( ! $draft instanceof ProductUrlDraft ) { throw new \InvalidArgumentException( 'SaveProductUseCase URL drafts must be ProductUrlDraft instances.' ); } $drafts[] = $draft; } $seen = array(); $result = array(); foreach ( $drafts as $draft ) { $key = $draft->providerType() . "\0" . ProductUrlRecord::hashUrl( $draft->url() ); if ( array_key_exists( $key, $seen ) ) { continue; } $seen[ $key ] = true; $result[] = $draft; } return $result; } /** * Find an already-normalized Amazon product through either parent or URL row. * * @param ProductSnapshot $snapshot Normalized snapshot to inspect. */ private function findExistingAmazonProduct( ProductSnapshot $snapshot ): ?ProductRecord { if ( $snapshot->provider() !== 'amazon' ) { return null; } $asin = $snapshot->providerProductId()->value(); if ( $this->urlRepository !== null ) { $existingUrl = $this->urlRepository->findByProviderItem( 'amazon', $asin ); if ( $existingUrl !== null ) { $record = $this->repository->findById( $existingUrl->productId() ); if ( $record !== null ) { return $record; } } } return $this->repository->findByProviderItem( 'amazon', $asin ); } /** * Persist child URL rows for an affilun product ID. * * @param int $productId Product ID. * @param list $drafts URL drafts. * @param \DateTimeImmutable $now Persistence timestamp. */ private function persistUrlDrafts( int $productId, array $drafts, \DateTimeImmutable $now ): void { if ( $this->urlRepository === null ) { return; } foreach ( $drafts as $draft ) { $hash = ProductUrlRecord::hashUrl( $draft->url() ); if ( $this->urlRepository->findByProductProviderUrl( $productId, $draft->providerType(), $hash ) !== null ) { continue; } $this->urlRepository->save( ProductUrlRecord::createNew( $productId, $draft->providerType(), $draft->providerItemId(), $draft->url(), $now, array( 'source' => $draft->source(), 'urlHash' => $hash, ) ) ); } } /** * Build a fresh {@see ProductRecord} for the insert path. * * @param ProductSnapshot $snapshot Snapshot to persist. * @param \DateTimeImmutable $now Clock-supplied "now". */ private function buildNewRecord( ProductSnapshot $snapshot, \DateTimeImmutable $now ): ProductRecord { [ $priceAmount, $priceCurrency, $priceLabel ] = self::extractPriceColumns( $snapshot->price() ); $keywords = $this->keywordExtractor->extract( $snapshot ); return new ProductRecord( null, $snapshot->provider(), $snapshot->providerProductId()->value(), self::computeCanonicalHash( $snapshot ), $snapshot->title(), $snapshot->brand(), $snapshot->description(), $snapshot->primaryImage(), self::encodeGalleryImages( $snapshot->galleryImages() ), $priceAmount, $priceCurrency, $priceLabel, $snapshot->availability()->status(), $snapshot->detailUrl(), $snapshot->affiliateUrl(), null, 1, 'fresh', 'active', null, $now, null, $now, null, null, $now, $now, $keywords->keywords(), $keywords->source() ); } /** * Build the post-update {@see ProductRecord} that preserves * the existing row's `id` and `createdAt`. * * Every other column is refreshed from the snapshot plus the * supplied "now" timestamp per the §6.4 sync-stamping rule. * * @param ProductRecord $existing Existing row. * @param ProductSnapshot $snapshot Snapshot driving the * update. * @param \DateTimeImmutable $now Clock-supplied "now". */ private function buildUpdatedRecord( ProductRecord $existing, ProductSnapshot $snapshot, \DateTimeImmutable $now ): ProductRecord { [ $priceAmount, $priceCurrency, $priceLabel ] = self::extractPriceColumns( $snapshot->price() ); $keywords = $this->keywordExtractor->extract( $snapshot ); return new ProductRecord( $existing->id(), $snapshot->provider(), $snapshot->providerProductId()->value(), self::computeCanonicalHash( $snapshot ), $snapshot->title(), $snapshot->brand(), $snapshot->description(), $snapshot->primaryImage(), self::encodeGalleryImages( $snapshot->galleryImages() ), $priceAmount, $priceCurrency, $priceLabel, $snapshot->availability()->status(), $snapshot->detailUrl(), $snapshot->affiliateUrl(), null, $existing->snapshotVersion(), 'fresh', $existing->lifecycleState(), $existing->archivedAt(), $now, $existing->lastRenderedAt(), $now, null, null, $existing->createdAt(), $now, $keywords->keywords(), $keywords->source() ); } /** * Extract the three persisted price columns from a * {@see PriceInfo} value, or return three nulls when the * snapshot carries no price. * * @param PriceInfo|null $price Snapshot price, or null. * * @return array{0: ?string, 1: ?string, 2: ?string} */ private static function extractPriceColumns( ?PriceInfo $price ): array { if ( $price === null ) { return array( null, null, null ); } return array( $price->amount(), $price->currency(), $price->label(), ); } /** * Encode the snapshot's gallery-image list as JSON, or null * when the list is empty. * * The §4.2 column is nullable; persisting `[]` would force * downstream consumers to disambiguate "empty list" from "no * data", so the empty case maps to null verbatim. * * @param list $galleryImages Snapshot gallery list. */ private static function encodeGalleryImages( array $galleryImages ): ?string { if ( $galleryImages === array() ) { return null; } if ( function_exists( 'wp_json_encode' ) ) { $encoded = wp_json_encode( $galleryImages ); } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit test environment without WordPress runtime; production code path uses wp_json_encode above. $encoded = json_encode( $galleryImages ); } if ( ! is_string( $encoded ) ) { return null; } return $encoded; } /** * Compute the §4.4 canonical hash for the supplied snapshot. * * Returns the raw SHA-256 hex digest (64 characters) of the * normalized title / brand / first-image-url-without-query * components. The Phase 2 schema declares * `canonical_hash char(64) NOT NULL` (see * `database-schema.md` §4.2 / §17.3) so the storage column * cannot accommodate the spec §4.4 pseudocode's `"ch1:" + * sha256(...)` prefix (68 chars). The version-prefix concern is * deferred to a dedicated `snapshot_version` column (already * present on `affilun_products`) so the version metadata is * preserved without overflowing the hash column. * * The implementation is deliberately minimal — it does not * consult JAN / ISBN-13 / EAN-13 attributes because those are * Phase 9 sync-pipeline concerns; the Phase 6 minimum surfaces a * stable, deterministic value that satisfies the * {@see ProductRecord} non-empty invariant. * * @param ProductSnapshot $snapshot Snapshot to hash. */ private static function computeCanonicalHash( ProductSnapshot $snapshot ): string { $normalize = static function ( string $value ): string { $lower = function_exists( 'mb_strtolower' ) ? mb_strtolower( $value, 'UTF-8' ) : strtolower( $value ); $collapsed = preg_replace( '/\s+/u', ' ', $lower ); if ( $collapsed === null ) { $collapsed = $lower; } return trim( $collapsed ); }; $titlePart = 't:' . $normalize( $snapshot->title() ); $brandPart = 'b:' . $normalize( $snapshot->brand() ?? '' ); $primary = $snapshot->primaryImage() ?? ''; $position = strpos( $primary, '?' ); if ( $position !== false ) { $primary = substr( $primary, 0, $position ); } $imagePart = 'i:' . substr( sha1( $primary ), 0, 12 ); $joined = $titlePart . '|' . $brandPart . '|' . $imagePart; return hash( 'sha256', $joined ); } }