, string): array)|null $transport */ public function __construct( private CloudRequestSigner $signer = new CloudRequestSigner(), ?callable $transport = null ) { $this->transport = $transport ?? self::defaultTransport(); } /** * Fetch campaign suggestions. * * @param CloudSettings $settings Cloud settings. * @param string $provider Provider. * @param string $date Date. * @param int $timestamp Timestamp. * @param string $nonce Nonce. * * @return list * * @throws InvalidArgumentException When Cloud settings are incomplete. * @throws RuntimeException When Cloud response fails. */ public function fetch( CloudSettings $settings, string $provider, string $date, int $timestamp, string $nonce ): array { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $provider = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? ''; $date = preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $date ) === 1 ? $date : gmdate( 'Y-m-d' ); $path = '/campaigns?provider=' . rawurlencode( $provider ) . '&date=' . rawurlencode( $date ); $headers = $this->signer->sign( $settings->siteId(), $settings->apiKey(), 'GET', $path, '', $timestamp, $nonce ); $response = ( $this->transport )( $settings->endpoint() . $path, $headers, '' ); $status = isset( $response['status'] ) ? (int) $response['status'] : 0; $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null; if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) { throw new RuntimeException( 'affilun Cloud campaign feed failed.' ); } return self::suggestionsFromPayload( $data ); } /** * Fetch shared campaigns plus product-specific opportunities. * * @param CloudSettings $settings Cloud settings. * @param string $provider Provider. * @param string $date Date. * @param list> $products Saved products. * @param int $timestamp Timestamp. * @param string $nonce Nonce. * * @return list * * @throws InvalidArgumentException When Cloud settings are incomplete. * @throws RuntimeException When Cloud response fails. */ public function fetchOpportunities( CloudSettings $settings, string $provider, string $date, array $products, int $timestamp, string $nonce ): array { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $provider = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? ''; $date = preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $date ) === 1 ? $date : gmdate( 'Y-m-d' ); $path = '/campaign-opportunities'; $body = self::jsonEncode( array( 'date' => $date, 'providers' => array( $provider ), 'products' => self::normalizeProducts( $products ), ) ); $headers = $this->signer->sign( $settings->siteId(), $settings->apiKey(), 'POST', $path, $body, $timestamp, $nonce ); $headers['Content-Type'] = 'application/json'; $response = ( $this->transport )( $settings->endpoint() . $path, $headers, $body ); $status = isset( $response['status'] ) ? (int) $response['status'] : 0; $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null; if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) { throw new RuntimeException( 'affilun Cloud campaign opportunities failed.' ); } return self::suggestionsFromPayload( $data ); } /** * Fetch prioritized revenue opportunities. * * @param CloudSettings $settings Cloud settings. * @param string $provider Provider. * @param string $date Date. * @param list> $products Saved products. * @param int $timestamp Timestamp. * @param string $nonce Nonce. * * @return list */ public function fetchRevenueOpportunities( CloudSettings $settings, string $provider, string $date, array $products, int $timestamp, string $nonce ): array { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $provider = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? ''; $date = preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $date ) === 1 ? $date : gmdate( 'Y-m-d' ); $path = '/revenue-opportunities'; $body = self::jsonEncode( array( 'date' => $date, 'providers' => array( $provider ), 'products' => self::normalizeProducts( $products ), ) ); $headers = $this->signer->sign( $settings->siteId(), $settings->apiKey(), 'POST', $path, $body, $timestamp, $nonce ); $headers['Content-Type'] = 'application/json'; $response = ( $this->transport )( $settings->endpoint() . $path, $headers, $body ); $status = isset( $response['status'] ) ? (int) $response['status'] : 0; $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null; if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) { throw new RuntimeException( 'affilun Cloud revenue opportunities failed.' ); } if ( ! self::payloadHasRevenueOpportunities( $data ) ) { throw new RuntimeException( 'affilun Cloud revenue opportunities payload is missing opportunities.' ); } return self::revenueOpportunitiesFromPayload( $data ); } /** * List persisted revenue opportunities. * * @param CloudSettings $settings Cloud settings. * @param string $provider Provider filter. * @param string $status Status filter. * @param int $timestamp Timestamp. * @param string $nonce Nonce. * * @return list */ public function listRevenueOpportunities( CloudSettings $settings, string $provider, string $status, int $timestamp, string $nonce ): array { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $provider = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? ''; $status = self::cleanOpportunityStatus( $status, true ); $query = array( 'limit' => '100', ); if ( $provider !== '' ) { $query['provider'] = $provider; } if ( $status !== '' ) { $query['status'] = $status; } $path = '/revenue-opportunities?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ); $headers = $this->signer->sign( $settings->siteId(), $settings->apiKey(), 'GET', $path, '', $timestamp, $nonce ); $response = ( $this->transport )( $settings->endpoint() . $path, $headers, '' ); $statusCode = isset( $response['status'] ) ? (int) $response['status'] : 0; $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null; if ( $statusCode < 200 || $statusCode >= 300 || ! is_array( $data ) ) { throw new RuntimeException( 'affilun Cloud revenue opportunity list failed.' ); } if ( ! self::payloadHasRevenueOpportunities( $data ) ) { throw new RuntimeException( 'affilun Cloud revenue opportunity list payload is missing opportunities.' ); } return self::revenueOpportunitiesFromPayload( $data ); } /** * Update one persisted revenue opportunity status. * * @param CloudSettings $settings Cloud settings. * @param string $opportunityId Opportunity id. * @param string $status New status. * @param int $timestamp Timestamp. * @param string $nonce Nonce. */ public function updateRevenueOpportunityStatus( CloudSettings $settings, string $opportunityId, string $status, int $timestamp, string $nonce ): CloudRevenueOpportunity { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $opportunityId = trim( $opportunityId ); $status = self::cleanOpportunityStatus( $status, false ); if ( $opportunityId === '' || preg_match( '/[\x00-\x1F\x7F]/', $opportunityId ) === 1 || $status === '' ) { throw new InvalidArgumentException( 'Invalid revenue opportunity status update.' ); } $path = '/revenue-opportunities/' . rawurlencode( $opportunityId ) . '/status'; $body = self::jsonEncode( array( 'status' => $status ) ); $headers = $this->signer->sign( $settings->siteId(), $settings->apiKey(), 'POST', $path, $body, $timestamp, $nonce ); $headers['Content-Type'] = 'application/json'; $response = ( $this->transport )( $settings->endpoint() . $path, $headers, $body ); $statusCode = isset( $response['status'] ) ? (int) $response['status'] : 0; $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null; if ( $statusCode < 200 || $statusCode >= 300 || ! is_array( $data ) ) { throw new RuntimeException( 'affilun Cloud revenue opportunity status update failed.' ); } $payload = isset( $data['data'] ) && is_array( $data['data'] ) ? $data['data'] : $data; $opportunity = isset( $payload['opportunity'] ) && is_array( $payload['opportunity'] ) ? $payload['opportunity'] : $payload; return CloudRevenueOpportunity::fromArray( $opportunity ); } /** * Normalize saved products for Cloud submission. * * @param list> $products Raw products. * * @return list> */ private static function normalizeProducts( array $products ): array { $normalized = array(); foreach ( $products as $product ) { $provider = is_scalar( $product['provider'] ?? null ) ? (string) $product['provider'] : ''; $providerItemId = is_scalar( $product['provider_item_id'] ?? null ) ? (string) $product['provider_item_id'] : ''; $provider = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? ''; $providerItemId = trim( $providerItemId ); if ( $provider === '' || $providerItemId === '' ) { continue; } $row = array( 'provider' => $provider, 'provider_item_id' => $providerItemId, ); foreach ( array( 'title', 'detail_url', 'affiliate_url' ) as $field ) { if ( is_scalar( $product[ $field ] ?? null ) ) { $value = trim( (string) $product[ $field ] ); if ( $value !== '' ) { $row[ $field ] = $value; } } } if ( isset( $product['metadata'] ) && is_array( $product['metadata'] ) ) { $row['metadata'] = $product['metadata']; } $normalized[] = $row; if ( count( $normalized ) >= 50 ) { break; } } return $normalized; } /** * Build suggestion rows from either legacy or opportunities responses. * * @param array $payload Response payload. * * @return list */ private static function suggestionsFromPayload( array $payload ): array { $data = isset( $payload['data'] ) && is_array( $payload['data'] ) ? $payload['data'] : $payload; $campaigns = isset( $data['campaigns'] ) && is_array( $data['campaigns'] ) ? $data['campaigns'] : array(); $opportunities = isset( $data['product_opportunities'] ) && is_array( $data['product_opportunities'] ) ? $data['product_opportunities'] : array(); $suggestions = array(); foreach ( array_merge( $campaigns, $opportunities ) as $item ) { if ( is_array( $item ) ) { $suggestions[] = CloudCampaignSuggestion::fromArray( $item ); } } return $suggestions; } /** * Build revenue opportunity rows from a response payload. * * @param array $payload Response payload. * * @return list */ private static function revenueOpportunitiesFromPayload( array $payload ): array { $data = isset( $payload['data'] ) && is_array( $payload['data'] ) ? $payload['data'] : $payload; $opportunities = isset( $data['opportunities'] ) && is_array( $data['opportunities'] ) ? $data['opportunities'] : array(); $mapped = array(); foreach ( $opportunities as $item ) { if ( is_array( $item ) ) { $mapped[] = CloudRevenueOpportunity::fromArray( $item ); } } return $mapped; } /** * Whether a response payload contains a revenue opportunity list. * * @param array $payload Response payload. */ private static function payloadHasRevenueOpportunities( array $payload ): bool { $data = isset( $payload['data'] ) && is_array( $payload['data'] ) ? $payload['data'] : $payload; return array_key_exists( 'opportunities', $data ) && is_array( $data['opportunities'] ); } /** * Clean opportunity status input. * * @param string $status Raw status. * @param bool $allowEmpty Whether an empty value is allowed. */ private static function cleanOpportunityStatus( string $status, bool $allowEmpty ): string { $status = strtolower( trim( $status ) ); if ( $status === '' && $allowEmpty ) { return ''; } return in_array( $status, array( 'new', 'seen', 'applied', 'dismissed' ), true ) ? $status : ''; } /** * JSON encode a request payload. * * @param array $payload Payload. * * @throws RuntimeException When encoding fails. */ private static function jsonEncode( array $payload ): string { if ( function_exists( 'wp_json_encode' ) ) { $body = wp_json_encode( $payload ); } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit fallback when WordPress is unavailable. $body = json_encode( $payload ); } if ( ! is_string( $body ) ) { throw new RuntimeException( 'Failed to encode affilun Cloud request.' ); } return $body; } /** * Default WordPress HTTP transport. * * @return callable * * @phpstan-return callable(string, array, string): array */ private static function defaultTransport(): callable { return static function ( string $url, array $headers, string $body ): array { $args = array( 'timeout' => 5, 'headers' => $headers, ); if ( $body === '' ) { $response = CloudHttpOptions::safeGet( $url, $args ); } else { $args['body'] = $body; $response = CloudHttpOptions::safePost( $url, $args ); } if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { return array( 'status' => 503, 'data' => null, ); } $raw = is_array( $response ) ? (string) wp_remote_retrieve_body( $response ) : ''; $decoded = json_decode( $raw, true ); return array( 'status' => is_array( $response ) ? (int) wp_remote_retrieve_response_code( $response ) : 0, 'data' => is_array( $decoded ) ? $decoded : null, ); }; } }