0` (defense in depth: the controller * already validates the route parameter, but the use case is * a callable seam that admin / CLI surfaces will reuse). * * 2. Look up the {@see ProductRecord} via * {@see ProductRepository::findById()}; missing rows raise * {@see ProductNotFoundException} so the controller can map * to the §15.2 `affilun_product_not_found` 404 envelope without * having to know the persistence shape. * * 3. Decide the §13.2 {@see SyncPriority} from the record's * `syncState` axis and `lastSyncedAt` timestamp using the * heuristic documented on * {@see self::priorityFor()}. * * 4. Delegate the actual enqueue to {@see SyncDispatcher}, which * owns the §10.2 payload shape (`product_id`, * `correlation_id`, `enqueued_at`), the §12.4 retry-policy * stamp, and the {@see SyncJobRecord} persistence. * * 5. Return an {@see EnqueueProductSyncResult} carrying both * the persisted job and the resolved correlation id, so the * controller can render the §11.2 success envelope without * round-tripping through {@see SyncDispatcher::payloadFor()}. * * Priority heuristic (documented on {@see self::priorityFor()}): * * - `syncState === 'broken'` → {@see SyncPriority::urgent()}. * Structural failure on the record needs the recovery path * ahead of routine work, matching §13.2's "broken → urgent" * mapping. * * - `lastSyncedAt === null` (never successfully synced) → * {@see SyncPriority::high()}. A never-synced row produces no * known-good snapshot for any consumer, so an explicit * admin-initiated resync should outrank routine `normal` * traffic without quite reaching `urgent`. * * - `syncState === 'stale'` or `syncState === 'expired'` → * {@see SyncPriority::high()}. The admin surface already * classified the record as past its freshness threshold; an * explicit user-initiated resync is the strongest signal we * have that the row needs to be refreshed promptly. * * - Anything else (typically `'fresh'`) → * {@see SyncPriority::normal()}. The admin still asked for a * resync, so the work runs at the default queue priority * rather than being demoted; demoting fresh-but-requested * resyncs would surprise admins debugging display issues. * * The heuristic deliberately does NOT consult {@see SyncPolicy} * (which would require a {@see \Affilun\Domain\Clock} * and threshold configuration). The §11.2 endpoint is invoked by * humans who already saw the admin-side state column; trusting * the persisted `syncState` value keeps this use case stateless * and side-effect-free outside the dispatcher call. * * @package Affilun\Application\Sync\UseCase */ declare(strict_types=1); namespace Affilun\Application\Sync\UseCase; use Affilun\Application\Product\Exception\ProductNotFoundException; use Affilun\Application\Sync\SyncDispatcher; use Affilun\Domain\Sync\SyncPriority; use Affilun\Domain\Sync\SyncState; use Affilun\Infrastructure\Persistence\ProductRecord; use Affilun\Infrastructure\Persistence\ProductRepository; use InvalidArgumentException; /** * Application-layer use case that drives the §11.2 * `POST /affilun/v1/sync/products/{id}` endpoint. * * See the file-level docblock for the validation order, the * priority-decision heuristic, and the rationale for not * consulting {@see \Affilun\Application\Sync\SyncPolicy} * here. */ final class EnqueueProductSyncUseCase { /** * Dispatcher that owns the §10.2 enqueue payload shape and the * §12.4 retry-policy stamp. */ private SyncDispatcher $dispatcher; /** * Persistence port used to confirm the product exists before * the dispatcher writes the queue row. */ private ProductRepository $products; /** * Construct the use case with explicit dependencies. * * @param SyncDispatcher $dispatcher Dispatcher owning the * §10.2 enqueue. * @param ProductRepository $products Repository owning the * `affilun_products` lookup. */ public function __construct( SyncDispatcher $dispatcher, ProductRepository $products ) { $this->dispatcher = $dispatcher; $this->products = $products; } /** * Enqueue a sync-product job for `$productId`. * * Validation / lookup / priority / enqueue order is documented * on the file-level docblock. * * @param int $productId Local `affilun_products.id` to * refresh; MUST be > 0. * @param string|null $correlationId Caller-supplied UUID v4 or * opaque token, or null to * let the dispatcher generate * a fresh UUID v4. * * @throws InvalidArgumentException When `$productId` is not a * positive integer. * @throws ProductNotFoundException When no `affilun_products` row * matches the supplied id. */ public function execute( int $productId, ?string $correlationId = null ): EnqueueProductSyncResult { if ( $productId <= 0 ) { $message = sprintf( 'EnqueueProductSyncUseCase requires a positive productId; got %d.', $productId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $record = $this->products->findById( $productId ); if ( $record === null ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing exception, never rendered to HTML. throw ProductNotFoundException::forId( $productId ); } $priority = $this->priorityFor( $record ); $job = $this->dispatcher->enqueueProductSync( $productId, $priority, $correlationId ); // The dispatcher stamps the resolved correlation id onto the // payload; decoding here keeps the result VO authoritative // regardless of whether the caller supplied one. $payload = SyncDispatcher::payloadFor( $job ); $resolvedCorrId = is_string( $payload['correlation_id'] ?? null ) ? (string) $payload['correlation_id'] : ( $correlationId ?? '' ); return new EnqueueProductSyncResult( $job, $resolvedCorrId ); } /** * Decide the §13.2 priority for a resync of `$record`. * * Heuristic (see file-level docblock for the rationale): * * - `syncState === 'broken'` → urgent. * - `lastSyncedAt === null` → high. * - `syncState === 'stale'` → high. * - `syncState === 'expired'` → high. * - Otherwise → normal. * * @param ProductRecord $record Record being resynced. */ private function priorityFor( ProductRecord $record ): SyncPriority { $syncState = $record->syncState(); if ( $syncState === SyncState::BROKEN ) { return SyncPriority::urgent(); } if ( $record->lastSyncedAt() === null ) { return SyncPriority::high(); } if ( $syncState === SyncState::STALE || $syncState === SyncState::EXPIRED ) { return SyncPriority::high(); } return SyncPriority::normal(); } }