HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.32
Disabled: pcntl_fork
Upload Files
File: //wordpress/plugins/wp-cloud-client/1.1.9/src/Integration/WooCommerceProductCaller.php
<?php

declare(strict_types=1);

namespace VPlugins\WPCloudClient\Integration;

use VPlugins\WPCloudClient\Settings\SettingsRepository;
use VPlugins\WPCloudClient\Support\Logger;

/**
 * Sends WooCommerce product events to wp-cloud-manager via HTTP.
 *
 * Handles new products, updates, and deletions. Schedules a single WP-Cron
 * retry on transport failure or 5xx response so that product saves are never
 * blocked by a slow or unavailable manager.
 *
 * TODO: ~95% of send/dispatch/scheduleRetry/buildUrl logic is duplicated in
 * WooCommerceOrderCaller. Extract a shared WPCloudHttpClient service to keep
 * future fixes from needing to land in two places.
 */
final class WooCommerceProductCaller {

	private const RETRY_HOOK = 'wpcc_wc_product_retry';

	/**
	 * Class constructor.
	 *
	 * @param SettingsRepository $settings Plugin settings.
	 * @param Logger             $logger   Logger service.
	 */
	public function __construct(
		private readonly SettingsRepository $settings,
		private readonly Logger $logger
	) {}

	/**
	 * Register all WooCommerce product hooks and the WP-Cron retry listener.
	 *
	 * @return void
	 */
	public function register(): void {
		add_action( 'woocommerce_new_product', [ $this, 'onNewProduct' ], 10, 2 );
		add_action( 'woocommerce_update_product', [ $this, 'onUpdateProduct' ], 10, 2 );
		add_action( 'woocommerce_trash_product', [ $this, 'onTrashProduct' ], 10, 1 );
		add_action( 'woocommerce_delete_product', [ $this, 'onDeleteProduct' ], 10, 1 );
		add_action( self::RETRY_HOOK, [ $this, 'executeRetry' ], 10, 3 );
	}

	/**
	 * Send a new product to wp-cloud-manager.
	 *
	 * @internal WooCommerce hook: woocommerce_new_product
	 *
	 * @param int    $productId Product ID.
	 * @param object $product   WC_Product instance.
	 * @return void
	 */
	public function onNewProduct( int $productId, object $product ): void {
		$payload    = WooCommerceHelper::extractProduct( $product );
		$modifiedTs = $this->productModifiedTs( $product );
		$this->dispatch( 'register-wc-product', $payload, $productId, 'new', $modifiedTs );
	}

	/**
	 * Send a product update to wp-cloud-manager.
	 *
	 * @internal WooCommerce hook: woocommerce_update_product
	 *
	 * @param int    $productId Product ID.
	 * @param object $product   WC_Product instance.
	 * @return void
	 */
	public function onUpdateProduct( int $productId, object $product ): void {
		$payload    = WooCommerceHelper::extractProduct( $product );
		$modifiedTs = $this->productModifiedTs( $product );
		$this->dispatch( 'register-wc-product', $payload, $productId, 'updated', $modifiedTs );
	}

	/**
	 * Send a trashed product deletion to wp-cloud-manager.
	 *
	 * @internal WooCommerce hook: woocommerce_trash_product
	 *
	 * @param int $productId Product ID.
	 * @return void
	 */
	public function onTrashProduct( int $productId ): void {
		$siteId  = (string) $this->settings->getSiteId();
		$payload = [
			'site_id' => $siteId,
			'id'      => $productId,
		];
		$this->dispatch( 'delete-wc-product', $payload, $productId, 'trash' );
	}

	/**
	 * Send a permanently deleted product to wp-cloud-manager.
	 *
	 * @internal WooCommerce hook: woocommerce_delete_product
	 *
	 * @param int $productId Product ID.
	 * @return void
	 */
	public function onDeleteProduct( int $productId ): void {
		$siteId  = (string) $this->settings->getSiteId();
		$payload = [
			'site_id' => $siteId,
			'id'      => $productId,
		];
		$this->dispatch( 'delete-wc-product', $payload, $productId, 'delete' );
	}

	/**
	 * Execute a scheduled one-shot retry for a failed product POST.
	 *
	 * @internal WP-Cron hook: wpcc_wc_product_retry
	 *
	 * Executes a single retry for a previously failed product POST. Does not
	 * reschedule further on failure — one retry per original event.
	 *
	 * @param string $route          Route segment, e.g. 'register-wc-product'.
	 * @param string $payloadJson    JSON-encoded payload from the original attempt.
	 * @param string $idempotencyKey Original idempotency key to reuse on retry.
	 * @return void
	 */
	public function executeRetry( string $route, string $payloadJson, string $idempotencyKey ): void {
		$url     = $this->buildUrl( $route );
		$payload = json_decode( $payloadJson, true );
		if ( ! is_array( $payload ) ) {
			$this->logger->error( 'WC product caller retry skipped — corrupt payload JSON', $route );
			return;
		}
		$this->send( $url, $payload, $idempotencyKey );
	}

	/**
	 * Build the full URL, compute the idempotency key, and call send().
	 *
	 * Delete events omit the modification timestamp from the key so it stays
	 * stable across retries (the product no longer exists to provide a timestamp).
	 *
	 * @param string               $route      Route segment.
	 * @param array<string, mixed> $payload    Request body.
	 * @param int                  $id         Product ID for the idempotency key.
	 * @param string               $event      Event type for the idempotency key.
	 * @param string               $modifiedTs Modification timestamp; omit for delete events.
	 * @return void
	 */
	private function dispatch( string $route, array $payload, int $id, string $event, string $modifiedTs = '' ): void {
		$url    = $this->buildUrl( $route );
		$siteId = (string) $this->settings->getSiteId();
		$parts  = [ $siteId, $id, $event ];
		if ( '' !== $modifiedTs ) {
			$parts[] = $modifiedTs;
		}

		$this->send( $url, $payload, implode( ':', $parts ), $route );
	}

	/**
	 * Execute the HTTP POST and handle the response.
	 *
	 * @param string               $url            Full endpoint URL.
	 * @param array<string, mixed> $payload        JSON body.
	 * @param string               $idempotencyKey X-WPCC-Idempotency-Key header value.
	 * @param string               $retryRoute     Route to schedule for retry on failure; empty disables retry.
	 * @return void
	 */
	private function send( string $url, array $payload, string $idempotencyKey, string $retryRoute = '' ): void {
		if ( empty( $url ) ) {
			return;
		}
		if ( ! $this->settings->isConfigured() ) {
			$this->logger->warning( 'WC product caller skipped — site ID or API key not configured', $url );
			return;
		}

		$headers = [
			'Content-Type'           => 'application/json',
			'X-WPCC-Site-ID'         => (string) $this->settings->getSiteId(),
			'X-WPCC-API-Key'         => (string) $this->settings->getApiKey(),
			'X-WPCC-Idempotency-Key' => $idempotencyKey,
		];

		$response = wp_remote_post(
			$url,
			[
				'body'        => wp_json_encode( $payload ),
				'data_format' => 'body',
				'headers'     => $headers,
				'timeout'     => 5,
			]
		);

		if ( is_wp_error( $response ) ) {
			$this->logger->error( 'WC product caller transport error', $response->get_error_message() );
			if ( '' !== $retryRoute ) {
				$this->scheduleRetry( $retryRoute, $payload, $idempotencyKey );
			}
			return;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );

		if ( $code >= 500 ) {
			$this->logger->error(
				'WC product caller server error',
				[
					'status' => $code,
					'url'    => $url,
				]
			);
			if ( '' !== $retryRoute ) {
				$this->scheduleRetry( $retryRoute, $payload, $idempotencyKey );
			}
		} elseif ( 401 === $code ) {
			// Three possible reasons: missing auth headers, invalid credentials, or site not eligible.
			// Log the body so on-call can distinguish credential bugs from routing-not-yet-flipped.
			$this->logger->error( 'WC product caller unauthorized — check site ID, API key, and route strategy', wp_remote_retrieve_body( $response ) );
		} elseif ( 400 === $code ) {
			$this->logger->error( 'WC product caller bad request — malformed payload', wp_remote_retrieve_body( $response ) );
		} elseif ( 405 === $code ) {
			$this->logger->error( 'WC product caller method not allowed — route misconfiguration', $url );
		} elseif ( 202 !== $code ) {
			$this->logger->error(
				'WC product caller unexpected response',
				[
					'status' => $code,
					'url'    => $url,
				]
			);
		}
	}

	/**
	 * Schedule a single WP-Cron retry 60 seconds from now.
	 *
	 * Avoids scheduling a duplicate if one is already pending for the same
	 * route + payload + idempotency-key combination. Stores the idempotency key
	 * so executeRetry can reuse it and avoid sending a different key on retry.
	 *
	 * @param string               $route          Route segment for the retry.
	 * @param array<string, mixed> $payload        Original payload.
	 * @param string               $idempotencyKey Original idempotency key.
	 * @return void
	 */
	private function scheduleRetry( string $route, array $payload, string $idempotencyKey ): void {
		$payloadJson = (string) wp_json_encode( $payload );
		if ( ! wp_next_scheduled( self::RETRY_HOOK, [ $route, $payloadJson, $idempotencyKey ] ) ) {
			$scheduled = wp_schedule_single_event( time() + 60, self::RETRY_HOOK, [ $route, $payloadJson, $idempotencyKey ] );
			if ( false === $scheduled ) {
				$this->logger->error( 'WC product caller failed to schedule retry', $route );
			}
		}
	}

	/**
	 * Build the full endpoint URL from the manager base URL and route segment.
	 *
	 * @param string $route Route segment, e.g. 'register-wc-product'.
	 * @return string Full URL, or empty string if manager URL is not configured.
	 */
	private function buildUrl( string $route ): string {
		$managerUrl = $this->settings->getManagerUrl();
		if ( empty( $managerUrl ) ) {
			return '';
		}
		return rtrim( $managerUrl, '/' ) . '/wordpress/v1/' . $route . '/';
	}

	/**
	 * Get the modification timestamp from a WC_Product object for the idempotency key.
	 *
	 * @param object $product WC_Product instance.
	 * @return string RFC3339 timestamp string, or current Unix timestamp as fallback.
	 */
	private function productModifiedTs( object $product ): string {
		if ( method_exists( $product, 'get_date_modified' ) ) {
			$date = $product->get_date_modified();
			if ( $date && method_exists( $date, 'format' ) ) {
				return $date->format( 'c' );
			}
		}
		return (string) time();
	}
}