diff --git a/src/Model/GoogleOptimizationScore.php b/src/Model/GoogleOptimizationScore.php new file mode 100644 index 0000000..5fc9604 --- /dev/null +++ b/src/Model/GoogleOptimizationScore.php @@ -0,0 +1,31 @@ + $campaigns */ + private function __construct( + array $raw, + public readonly ?float $score, + public readonly ?float $weight, + public readonly array $campaigns, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::float($data, 'score'), + self::float($data, 'weight'), + GoogleOptimizationScoreCampaign::listFrom($data['campaigns'] ?? []), + ); + } +} diff --git a/src/Model/GoogleOptimizationScoreCampaign.php b/src/Model/GoogleOptimizationScoreCampaign.php new file mode 100644 index 0000000..441a344 --- /dev/null +++ b/src/Model/GoogleOptimizationScoreCampaign.php @@ -0,0 +1,30 @@ + $data */ + protected static function float(array $data, string $name): ?float + { + $value = self::field($data, $name); + if (is_int($value) || is_float($value)) { + return (float) $value; + } + if (is_string($value) && is_numeric($value)) { + return (float) $value; + } + + return null; + } + /** @param array $data */ protected static function bool(array $data, string $name): ?bool { diff --git a/src/Resource/GoogleAdsResource.php b/src/Resource/GoogleAdsResource.php index 1753eb2..91ded0d 100644 --- a/src/Resource/GoogleAdsResource.php +++ b/src/Resource/GoogleAdsResource.php @@ -12,6 +12,8 @@ use Fopost\Sdk\Model\GoogleKeyword; use Fopost\Sdk\Model\GoogleKeywordIdea; use Fopost\Sdk\Model\GoogleLocalServicesLead; +use Fopost\Sdk\Model\GoogleOptimizationScore; +use Fopost\Sdk\Model\GoogleRecommendation; use Fopost\Sdk\Model\GoogleSearchTerm; use Fopost\Sdk\Model\GoogleSharedSet; @@ -446,6 +448,75 @@ public function localServicesLeads( ))); } + // ── Recommendations ── + + /** + * Google's own read on what the account should change next. + * + * @param array $types narrows to those recommendation types + * @return array + */ + public function recommendations( + string $connectionId, + string $customerId, + array $types = [], + ?string $workspaceId = null, + ): array { + $extra = $types === [] ? [] : ['types' => implode(',', $types)]; + + return GoogleRecommendation::listFrom(self::unwrap($this->http->get( + '/ads/google/recommendations', + self::params($connectionId, $customerId, $workspaceId, $extra), + ))); + } + + /** The account's score and weight, and the score of each live campaign. */ + public function optimizationScore( + string $connectionId, + string $customerId, + ?string $workspaceId = null, + ): GoogleOptimizationScore { + return GoogleOptimizationScore::fromArray(self::unwrap($this->http->get( + '/ads/google/optimization-score', + self::params($connectionId, $customerId, $workspaceId), + ))); + } + + /** + * Applies each one, which changes what the live account serves or bids, and + * reports how many landed. Needs `publish` as well as `ads`. + * + * @param array $ids + */ + public function applyRecommendations( + string $workspaceId, + string $connectionId, + string $customerId, + array $ids, + ): int { + $body = self::scope($workspaceId, $connectionId, $customerId) + ['ids' => array_values($ids)]; + $result = self::unwrap($this->http->post('/ads/google/recommendations/apply', $body)); + + return is_array($result) && is_int($result['applied'] ?? null) ? $result['applied'] : 0; + } + + /** + * Hides each one so Google stops surfacing it. Needs `publish` as well as `ads`. + * + * @param array $ids + */ + public function dismissRecommendations( + string $workspaceId, + string $connectionId, + string $customerId, + array $ids, + ): int { + $body = self::scope($workspaceId, $connectionId, $customerId) + ['ids' => array_values($ids)]; + $result = self::unwrap($this->http->post('/ads/google/recommendations/dismiss', $body)); + + return is_array($result) && is_int($result['dismissed'] ?? null) ? $result['dismissed'] : 0; + } + // ── Conversions ── /** @return array */ diff --git a/tests/AdsTikTokTest.php b/tests/AdsTikTokTest.php index 8885b37..8908b93 100644 --- a/tests/AdsTikTokTest.php +++ b/tests/AdsTikTokTest.php @@ -32,7 +32,14 @@ public function testIdentitiesAndSparkPostsReadTheRightPaths(): void public function testSparkPostIdAndSmartPlusTravelInTheBody(): void { - $this->transport->push(201, ['data' => ['id' => 'ad_1', 'workspaceId' => 'w_1', 'kind' => 'ad', 'name' => 'Spark', 'goal' => 'traffic', 'status' => 'paused']]); + $this->transport->push(201, ['data' => [ + 'id' => 'ad_1', + 'workspaceId' => 'w_1', + 'kind' => 'ad', + 'name' => 'Spark', + 'goal' => 'traffic', + 'status' => 'paused', + ]]); $this->client()->ads()->create( 'w_1', 'conn_1', diff --git a/tests/GoogleAdsTest.php b/tests/GoogleAdsTest.php index bf84c26..8c2f273 100644 --- a/tests/GoogleAdsTest.php +++ b/tests/GoogleAdsTest.php @@ -99,6 +99,42 @@ public function testQueryReturnsRowsAsGoogleSendsThem(): void $this->assertStringContainsString('/ads/insights/query', $this->transport->last()['url']); } + public function testRecommendationsJoinTheTypesFilter(): void + { + $this->transport->push(200, ['data' => [[ + 'id' => 'customers/1234567890/recommendations/ABC~1', + 'type' => 'KEYWORD', + 'campaignId' => '1234567890~campaign~55', + 'dismissed' => false, + 'impact' => ['baseClicks' => 10, 'potentialClicks' => 25], + ]]]); + + $rows = $this->client()->ads()->google()->recommendations( + 'conn_1', + '1234567890', + ['KEYWORD', 'TARGET_CPA_OPT_IN'], + ); + + $this->assertSame('KEYWORD', $rows[0]->type); + $this->assertSame(25.0, $rows[0]->impact?->potentialClicks); + $this->assertStringContainsString('types=KEYWORD%2CTARGET_CPA_OPT_IN', $this->transport->last()['url']); + } + + public function testApplyRecommendationsSendsTheIds(): void + { + $this->transport->push(200, ['data' => ['applied' => 1]]); + + $applied = $this->client()->ads()->google()->applyRecommendations( + 'ws_1', + 'conn_1', + '1234567890', + ['customers/1234567890/recommendations/ABC~1'], + ); + + $this->assertSame(1, $applied); + $this->assertStringContainsString('/ads/google/recommendations/apply', $this->transport->last()['url']); + } + public function testAuthorizeGoogleHasItsOwnRoute(): void { $this->transport->push(200, ['data' => ['url' => 'https://accounts.google.com/o/x']]);